@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/app-utils/webhook-delivery.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 * DID THE WEBHOOK ACTUALLY DO ANYTHING? (AGL-1954)\n *\n * Every signal we have about the billing webhook is a signal about the\n * REQUEST. Stripe records `delivery_success` off the status code. The\n * idempotency claim in `stripeEvents` records that the signature verified and\n * the handler was entered. `/api/health/billing` (AGL-1924) counts what\n * Stripe attempted and failed to deliver. All three read green for the one\n * failure this module exists to catch: **a handler that answers 200 and moves\n * nothing.**\n *\n * That is not hypothetical here. AGL-1798 is exactly it — `charge.refunded`\n * was never subscribed on the live destination, so AGL-1546's entitlement\n * revocation had no trigger, and every other indicator looked fine for as\n * long as it lasted. AGL-1551 is the same class from the other side: a week\n * of 100% rejected deliveries behind a green \"Active\" badge.\n *\n * ## The honest signal is an EFFECT, not a return code\n *\n * A check that asserts \"the handler ran\" is nearly as blind as the 200 —\n * the handler DID run in the AGL-1798 shape, it just had no work registered\n * for the event. So the route reports what it COMMITTED: an org's plan\n * mirrored, a `platformRevenue` row written, a refund stamped, an orphan\n * recorded, an audit row appended, a plugin claiming the event. Those are the\n * things whose absence is the bug.\n *\n * ## Three outcomes, and conflating the middle two is its own failure\n *\n * A binary \"did it write something\" would fire on every legitimately\n * irrelevant delivery — a tenant shopper's subscription carries no\n * `metadata.orgId` and correctly moves nothing on OUR side; a marketplace\n * refund resolves to no workspace customer and is correctly left to the\n * plugins; a `won` dispute nobody claimed reversed no money and correctly\n * wakes nobody. Alerting on those is alert fatigue, which ends with the alarm\n * muted and the real one lost inside it.\n *\n * So a branch that decides an event is not its business must SAY SO, by\n * name, through {@link WebhookEffectLedger.skip}. That is what separates:\n *\n * | outcome | meaning | alarm |\n * |-----------|----------------------------------------------------|-------|\n * | `acted` | something durable committed, or a plugin claimed it | no |\n * | `ignored` | a branch named a reason, or we never asked for this event type | no |\n * | `inert` | an event we deliberately subscribe to produced NEITHER | YES |\n *\n * `inert` is the whole point. Falling off the end of the dispatch with no\n * effect and no stated reason is precisely what a deleted write, an\n * unregistered plugin handler, or a renamed event type looks like from\n * inside the process — and it is the only one of the three that nothing else\n * in this system can see.\n *\n * ## Why the \"did we ask for it\" test is the required-events list\n *\n * Stripe delivers what the destination is subscribed to, and the destination\n * is created by `tools/scripts/setup-stripe.mjs` from `WEBHOOK_EVENTS`. An\n * event type outside that list reaching us is either a hand-added\n * subscription or a Connect delivery arriving at the platform destination —\n * unremarkable, and never our silent-drop bug, because we never claimed to\n * handle it. An event type INSIDE it is one we went out of our way to ask\n * Stripe for, so producing nothing from it is a contradiction by\n * construction.\n *\n * `REQUIRED_WEBHOOK_EVENTS` below is a copy of that list, and a copy is a\n * drift hazard — so `webhook-delivery.spec.ts` reads the `.mjs` and fails on\n * any divergence, in either direction. The alternative (moving the list\n * across the nx boundary out of `tools/`) is a wider change than this\n * warrants and is tracked on AGL-1948.\n *\n * Pure: no clock, no I/O, no Firestore. The route records, this decides, the\n * spec exercises every branch without a network.\n */\n\n/** What a single delivery turned out to be. */\nexport type WebhookDeliveryOutcome = 'acted' | 'ignored' | 'inert'\n\n/**\n * The event types the platform destination is subscribed to, mirroring\n * `WEBHOOK_EVENTS` in `tools/scripts/lib/stripe-webhook-health.mjs`.\n *\n * The list is the definition of \"we asked for this\", which is what makes a\n * no-op delivery of one of them a defect rather than a non-event. Kept in\n * the same order as the source so a diff between the two reads cleanly.\n */\nexport const REQUIRED_WEBHOOK_EVENTS: readonly string[] = [\n 'customer.subscription.created',\n 'customer.subscription.updated',\n 'customer.subscription.deleted',\n 'checkout.session.completed',\n 'invoice.finalized',\n 'invoice.paid',\n 'invoice.payment_failed',\n 'charge.refunded',\n 'charge.dispute.created',\n 'charge.dispute.closed',\n // AI overage charged as it accrues (AGL-3011), in the source list's order.\n 'invoice.voided',\n 'invoice.marked_uncollectible',\n 'customer.updated',\n 'payment_method.attached',\n 'payment_method.detached',\n // Fraud signals to staff (AGL-3356).\n 'radar.early_fraud_warning.created',\n 'review.opened',\n]\n\n/**\n * How the CONNECT destination identifies itself (AGL-1948), mirroring\n * `CONNECT_SCOPE_METADATA_KEY` / `CONNECT_SCOPE_METADATA_VALUE` in\n * `tools/scripts/lib/stripe-webhook-health.mjs`.\n *\n * Connected-account events are delivered only to a destination created with\n * `connect: true`, and Stripe's API does not report that flag back on the\n * endpoint object — so the destination is identified by metadata we set at\n * creation instead. Same copy-and-guard arrangement as the list above.\n */\nexport const CONNECT_SCOPE_METADATA_KEY = 'aglyn_scope'\nexport const CONNECT_SCOPE_METADATA_VALUE = 'connect'\n\n/**\n * What the Connect destination must carry, mirroring\n * `CONNECT_WEBHOOK_EVENTS` in the same script lib.\n *\n * `account.updated` is the whole point of it: AGL-1997's `syncConnectAccountStatus`\n * is what stops a merchant whose Stripe account was later restricted from\n * going on selling against a stale `stripeChargesEnabled`, with the SHOPPER\n * meeting the failure at payment time. Without this event the handler cannot\n * run — which is exactly the state AGL-2122 found and fixed.\n */\nexport const REQUIRED_CONNECT_WEBHOOK_EVENTS: readonly string[] = [\n 'account.updated',\n 'payout.failed',\n 'payout.paid',\n]\n\n/**\n * Does this Stripe endpoint object carry our Connect scope marker?\n *\n * Coverage for it reuses `unsubscribedRequiredEvents` with\n * `REQUIRED_CONNECT_WEBHOOK_EVENTS` as the `required` argument — the wildcard\n * and null handling are identical questions, and a second copy of that logic\n * is a second place for it to drift.\n */\nexport function isConnectWebhookEndpoint(endpoint: unknown): boolean {\n const metadata = (endpoint as { metadata?: Record<string, unknown> })?.metadata\n return metadata?.[CONNECT_SCOPE_METADATA_KEY] === CONNECT_SCOPE_METADATA_VALUE\n}\n\n/**\n * A per-delivery record of what actually committed.\n *\n * Deliberately append-only and stringly-typed: the reasons are read by a\n * human in a log line and by a spec, never switched on. Keeping it dumb is\n * what stops it growing into a second dispatch table that can disagree with\n * the real one.\n */\nexport interface WebhookEffectLedger {\n /** Durable things this delivery committed, in the order they landed. */\n readonly effects: readonly string[]\n /** Reasons a branch decided the event was none of its business. */\n readonly skips: readonly string[]\n /**\n * Record a committed effect. Call this AFTER the write it names, so a\n * throw between the two cannot claim an effect that never landed.\n */\n effect(name: string): void\n /**\n * Record a deliberate no-op and why. This is the difference between \"not\n * ours\" and \"broken\", and it is the half that prevents alert fatigue.\n */\n skip(reason: string): void\n}\n\nexport function createWebhookEffectLedger(): WebhookEffectLedger {\n const effects: string[] = []\n const skips: string[] = []\n return {\n effects,\n skips,\n effect(name: string) {\n effects.push(name)\n },\n skip(reason: string) {\n skips.push(reason)\n },\n }\n}\n\n/** Firestore methods that MUTATE. Everything else is a read or a builder. */\nconst WRITE_METHODS = new Set(['set', 'update', 'create', 'delete', 'add'])\n\n/**\n * Wrap a Firestore handle so every write it commits lands in the ledger.\n *\n * ## Why this exists rather than a `ledger.effect()` beside each write\n *\n * A hand-placed note next to a write is a check that CANNOT FAIL in the one\n * way that matters. Delete the write and leave the note, and the ledger\n * cheerfully reports an effect that never happened — which is the same\n * \"asserts its own literals\" defect the health probes are written to avoid.\n * The note has to be caused by the write, not merely adjacent to it.\n *\n * So the effect is recorded from INSIDE the call, after it resolves. A write\n * that throws records nothing (it did not commit); a write that is deleted\n * from the source records nothing (it is not called). There is no edit to\n * the handler that removes the work and keeps the signal.\n *\n * ## What it deliberately does not see\n *\n * Writes issued through a DIFFERENT Firestore handle — the shared\n * `tenant-data-admin` writers (`writeOrgBilling`, `notifyOrgAdmins`,\n * `notifyStaff`), which each open their own — and anything a plugin does\n * inside its own handler. Those are noted explicitly by the caller where\n * they are the only consequence of a branch, and the comments at those call\n * sites say so. The boundary is stated rather than hidden: this covers the\n * route's own writes, which is where the route's own bugs live.\n *\n * A Proxy rather than a hand-written facade because the wrapped object is\n * passed on to `updateExisting` and friends, which call methods this module\n * has never heard of. A facade would have to enumerate them and would\n * silently drop the ones it forgot.\n */\nexport function observeWrites<T extends object>(\n handle: T,\n ledger: WebhookEffectLedger,\n label = 'firestore',\n): T {\n return new Proxy(handle, {\n get(target, property, receiver) {\n const value = Reflect.get(target, property, receiver)\n if (typeof value !== 'function' || typeof property !== 'string') {\n return value\n }\n const method = property\n return (...args: unknown[]) => {\n const result = (value as (...a: unknown[]) => unknown).apply(\n target,\n args,\n )\n // `collection('orgs')` names the thing being written, so the ledger\n // reads `orgs.update` rather than `firestore.update`. Everything\n // else inherits the label it was reached through.\n const nextLabel =\n method === 'collection' && typeof args[0] === 'string'\n ? (args[0] as string)\n : label\n if (WRITE_METHODS.has(method)) {\n // Recorded on RESOLUTION. A rejected write committed nothing and\n // must not read as an effect.\n return Promise.resolve(result).then((settled) => {\n ledger.effect(`${nextLabel}.${method}`)\n return settled\n })\n }\n // Builders (`collection`, `doc`, `where`, `limit`) return objects\n // that can themselves be written through, so the wrapper follows\n // them. Thenables are returned bare: wrapping a promise would make\n // `then` look like a builder and recurse forever.\n if (\n result &&\n typeof result === 'object' &&\n typeof (result as { then?: unknown }).then !== 'function'\n ) {\n return observeWrites(result as object, ledger, nextLabel)\n }\n return result\n }\n },\n })\n}\n\nexport interface WebhookDeliveryVerdict {\n outcome: WebhookDeliveryOutcome\n /** One short phrase naming WHY, for the log line and the spec. */\n reason: string\n}\n\n/**\n * Classify one delivery.\n *\n * Order is the argument:\n *\n * 1. **Any effect, or any plugin claim, wins.** A delivery that both wrote\n * something and skipped something else did work; a partially-skipped\n * handler is not an idle one.\n * 2. **A named skip is an answer.** Reaching a branch that consciously\n * decided \"not ours\" is a handled event, not a dropped one.\n * 3. **An event type we never subscribed to is not our problem.** Nothing\n * promised to handle it.\n * 4. **Everything else is inert** — and by elimination that means: we asked\n * Stripe for this event, we received it, no handler claimed it, no branch\n * wrote anything, and no branch could say why.\n */\nexport function classifyWebhookDelivery(input: {\n type: string\n effects: readonly string[]\n skips: readonly string[]\n claimed: boolean\n /** Overridable so the spec can prove the rule rather than the list. */\n required?: readonly string[]\n}): WebhookDeliveryVerdict {\n const { type, effects, skips, claimed } = input\n const required = input.required ?? REQUIRED_WEBHOOK_EVENTS\n if (effects.length > 0) return { outcome: 'acted', reason: effects[0] }\n if (claimed) return { outcome: 'acted', reason: 'plugin-claimed' }\n if (skips.length > 0) return { outcome: 'ignored', reason: skips[0] }\n if (!required.includes(type)) {\n return { outcome: 'ignored', reason: 'not-subscribed' }\n }\n return { outcome: 'inert', reason: 'no-effect' }\n}\n\n/**\n * How long after Stripe CREATES an event its first delivery attempt may\n * plausibly take to reach us, in seconds. Mirrors `RETRY_LAG_SECONDS` in\n * `tools/scripts/lib/stripe-webhook-health.mjs`, under the same\n * read-the-`.mjs`-as-text drift guard as the lists above.\n *\n * Calibrated against the live account on 2026-08-18: the five deliveries that\n * succeeded first time landed in 1.0–3.7s; the one that did not —\n * `evt_1U49XtDYHP4psn7hA9VHPnZz`, whose three 400s ARE the three failures the\n * Stripe Dashboard was showing — landed 16,665s (4h 37m) late. Two minutes\n * sits three orders of magnitude clear of the healthy band and still inside\n * Stripe's first automatic retry interval, so it separates the two without\n * straddling either.\n */\nexport const RETRY_LAG_SECONDS = 120\n\n/**\n * What a delivery's lag says about the attempts BEFORE it.\n *\n * `unknown` is a first-class outcome, not a synonym for either answer. See\n * {@link classifyDeliveryLag} for why that matters more here than usual.\n */\nexport type WebhookDeliveryAttempt = 'first-attempt' | 'retried' | 'unknown'\n\n/** Why {@link classifyDeliveryLag} answered as it did. */\nexport type WebhookDeliveryLagReason =\n | 'measured'\n | 'created-absent'\n | 'created-not-a-number'\n | 'created-non-positive'\n | 'received-unusable'\n | 'clock-skew'\n\nexport interface WebhookDeliveryLagVerdict {\n attempt: WebhookDeliveryAttempt\n /** Seconds between `event.created` and our claim. Null unless `measured`. */\n lagSeconds: number | null\n reason: WebhookDeliveryLagReason\n}\n\n/**\n * DID THIS DELIVERY ONLY LAND ON A RETRY? (AGL-2039, the last arm of AGL-1948)\n *\n * ## The reconciliation this exists to close\n *\n * `/api/health/billing` scores EVENTS and `GET /v1/events?delivery_success=false`\n * is a TERMINAL-state filter: it selects events that are still pending or\n * failed EVERY attempt. An event that 400s three times and then succeeds on\n * the fourth reads back `delivery_success: true` and is, to that filter,\n * indistinguishable from one that succeeded immediately. The Stripe Dashboard\n * scores delivery ATTEMPTS, so the same hour reads 0% here and 30% there —\n * and both numbers are right. The three real failures were visible only in\n * the second reading, and each was a mirror that did not run when it should\n * have.\n *\n * The recoverable half of the attempt history is in our own data. The route\n * claims `stripeEvents/{id}` AFTER signature verification and DELETES the\n * claim when a handler throws, so the stamp always records the attempt that\n * actually got through. The distance from `event.created` to that stamp is\n * the delivery lag, and a lag of minutes means an earlier attempt failed.\n *\n * ## Why this is decided at WRITE time, not at read time\n *\n * The obvious implementation reads `receivedAt` off every claim document in\n * the window and subtracts. That turns a `.count()` aggregation — one\n * integer, zero documents read — into a document scan on a PUBLIC,\n * unauthenticated health endpoint polled every five minutes. Deciding here,\n * once, at the moment of the claim, lets the probe stay an aggregation over a\n * marker field, exactly like AGL-1954's `inertAtMs`.\n *\n * ## Absent, zero and unknown are THREE different things\n *\n * `strictNullChecks` is off repo-wide, so an absent `created` folds to falsy\n * and a naive `Number(event?.created ?? 0)` yields 0 — which subtracts to a\n * lag of ~1.7 BILLION seconds and reports every such delivery as a retry,\n * forever, on a perfectly healthy webhook. A monitoring signal that reds on\n * its own missing input is worse than no signal, because it is the thing that\n * gets muted. So each of those inputs is rejected by name:\n *\n * | input | attempt | reason |\n * |-----------------------------|------------|-------------------------|\n * | field absent / null | `unknown` | `created-absent` |\n * | a string, NaN, Infinity | `unknown` | `created-not-a-number` |\n * | `0` or negative | `unknown` | `created-non-positive` |\n * | our own stamp unusable | `unknown` | `received-unusable` |\n * | created in the FUTURE by more than the bar | `unknown` | `clock-skew` |\n *\n * A lag between `-threshold` and `+threshold` is `first-attempt`: sub-second\n * clock skew between Stripe's clock and ours is ordinary and is not evidence\n * of anything.\n *\n * Pure: no clock, no I/O. The caller supplies both timestamps.\n */\nexport function classifyDeliveryLag(input: {\n /** `event.created`, in unix SECONDS, exactly as Stripe sent it. */\n eventCreatedSeconds: unknown\n /** When we claimed the event, in unix MILLISECONDS. */\n receivedAtMs: unknown\n /** Overridable so the spec can prove the rule rather than the constant. */\n thresholdSeconds?: number\n}): WebhookDeliveryLagVerdict {\n const threshold = input.thresholdSeconds ?? RETRY_LAG_SECONDS\n const created = input.eventCreatedSeconds\n if (created === undefined || created === null) {\n return { attempt: 'unknown', lagSeconds: null, reason: 'created-absent' }\n }\n if (typeof created !== 'number' || !Number.isFinite(created)) {\n return {\n attempt: 'unknown',\n lagSeconds: null,\n reason: 'created-not-a-number',\n }\n }\n // Zero is the value an absent field folds to under the repo's null rules,\n // and no Stripe event was created at the epoch. Rejected by name so it can\n // never be mistaken for a measurement.\n if (created <= 0) {\n return {\n attempt: 'unknown',\n lagSeconds: null,\n reason: 'created-non-positive',\n }\n }\n const received = input.receivedAtMs\n if (typeof received !== 'number' || !Number.isFinite(received) || received <= 0) {\n return { attempt: 'unknown', lagSeconds: null, reason: 'received-unusable' }\n }\n const lagSeconds = received / 1000 - created\n // An event stamped in OUR future by more than the threshold is a clock\n // disagreement, not a fast delivery, and reporting it as a healthy first\n // attempt would be an answer we cannot support.\n if (lagSeconds < -threshold) {\n return { attempt: 'unknown', lagSeconds: null, reason: 'clock-skew' }\n }\n return {\n attempt: lagSeconds > threshold ? 'retried' : 'first-attempt',\n lagSeconds,\n reason: 'measured',\n }\n}\n\n/**\n * Which required events is a destination NOT subscribed to? (AGL-1948)\n *\n * The same blind spot from the configuration side, and the cheaper half of\n * it: a subscription removed by hand in the Stripe dashboard produces no\n * failed delivery, no rejected request and no inert one either — Stripe\n * simply stops sending, and every count on the health probe reads a\n * perfectly healthy zero. `charge.refunded` was missing from the live\n * endpoint for exactly this reason (AGL-1798) and the only thing that ever\n * noticed was a script nobody ran.\n *\n * `['*']` is Stripe's wildcard subscription and covers everything.\n *\n * Returns the names, sorted, so the probe body is stable and a diff between\n * two readings is meaningful.\n */\nexport function unsubscribedRequiredEvents(\n enabledEvents: readonly string[] | null | undefined,\n required: readonly string[] = REQUIRED_WEBHOOK_EVENTS,\n): string[] {\n if (!enabledEvents) return []\n if (enabledEvents.includes('*')) return []\n const enabled = new Set(enabledEvents)\n return required.filter((event) => !enabled.has(event)).sort()\n}\n"],"names":["REQUIRED_WEBHOOK_EVENTS","CONNECT_SCOPE_METADATA_KEY","CONNECT_SCOPE_METADATA_VALUE","REQUIRED_CONNECT_WEBHOOK_EVENTS","isConnectWebhookEndpoint","endpoint","metadata","createWebhookEffectLedger","effects","skips","effect","name","push","skip","reason","WRITE_METHODS","Set","observeWrites","handle","ledger","label","Proxy","get","target","property","receiver","value","Reflect","method","args","result","apply","nextLabel","has","Promise","resolve","then","settled","classifyWebhookDelivery","input","type","claimed","required","length","outcome","includes","RETRY_LAG_SECONDS","classifyDeliveryLag","threshold","thresholdSeconds","created","eventCreatedSeconds","undefined","attempt","lagSeconds","Number","isFinite","received","receivedAtMs","unsubscribedRequiredEvents","enabledEvents","enabled","filter","event","sort"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsEC,GAED,6CAA6C,GAG7C;;;;;;;CAOC,GACD,OAAO,MAAMA,0BAA6C;IACxD;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA,2EAA2E;IAC3E;IACA;IACA;IACA;IACA;IACA,qCAAqC;IACrC;IACA;CACD,CAAA;AAED;;;;;;;;;CASC,GACD,OAAO,MAAMC,6BAA6B,cAAa;AACvD,OAAO,MAAMC,+BAA+B,UAAS;AAErD;;;;;;;;;CASC,GACD,OAAO,MAAMC,kCAAqD;IAChE;IACA;IACA;CACD,CAAA;AAED;;;;;;;CAOC,GACD,OAAO,SAASC,yBAAyBC,QAAiB;IACxD,MAAMC,WAAYD,4BAAD,AAACA,SAAqDC,QAAQ;IAC/E,OAAOA,CAAAA,4BAAAA,QAAU,CAACL,2BAA2B,MAAKC;AACpD;AA2BA,OAAO,SAASK;IACd,MAAMC,UAAoB,EAAE;IAC5B,MAAMC,QAAkB,EAAE;IAC1B,OAAO;QACLD;QACAC;QACAC,QAAOC,IAAY;YACjBH,QAAQI,IAAI,CAACD;QACf;QACAE,MAAKC,MAAc;YACjBL,MAAMG,IAAI,CAACE;QACb;IACF;AACF;AAEA,2EAA2E,GAC3E,MAAMC,gBAAgB,IAAIC,IAAI;IAAC;IAAO;IAAU;IAAU;IAAU;CAAM;AAE1E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GACD,OAAO,SAASC,cACdC,MAAS,EACTC,MAA2B,EAC3BC,QAAQ,WAAW;IAEnB,OAAO,IAAIC,MAAMH,QAAQ;QACvBI,KAAIC,MAAM,EAAEC,QAAQ,EAAEC,QAAQ;YAC5B,MAAMC,QAAQC,QAAQL,GAAG,CAACC,QAAQC,UAAUC;YAC5C,IAAI,OAAOC,UAAU,cAAc,OAAOF,aAAa,UAAU;gBAC/D,OAAOE;YACT;YACA,MAAME,SAASJ;YACf,OAAO,CAAC,GAAGK;gBACT,MAAMC,SAAS,AAACJ,MAAuCK,KAAK,CAC1DR,QACAM;gBAEF,oEAAoE;gBACpE,iEAAiE;gBACjE,kDAAkD;gBAClD,MAAMG,YACJJ,WAAW,gBAAgB,OAAOC,IAAI,CAAC,EAAE,KAAK,WACzCA,IAAI,CAAC,EAAE,GACRT;gBACN,IAAIL,cAAckB,GAAG,CAACL,SAAS;oBAC7B,iEAAiE;oBACjE,8BAA8B;oBAC9B,OAAOM,QAAQC,OAAO,CAACL,QAAQM,IAAI,CAAC,CAACC;wBACnClB,OAAOT,MAAM,CAAC,GAAGsB,UAAU,CAAC,EAAEJ,QAAQ;wBACtC,OAAOS;oBACT;gBACF;gBACA,kEAAkE;gBAClE,iEAAiE;gBACjE,mEAAmE;gBACnE,kDAAkD;gBAClD,IACEP,UACA,OAAOA,WAAW,YAClB,OAAO,AAACA,OAA8BM,IAAI,KAAK,YAC/C;oBACA,OAAOnB,cAAca,QAAkBX,QAAQa;gBACjD;gBACA,OAAOF;YACT;QACF;IACF;AACF;AAQA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASQ,wBAAwBC,KAOvC;QAEkBA;IADjB,MAAM,EAAEC,IAAI,EAAEhC,OAAO,EAAEC,KAAK,EAAEgC,OAAO,EAAE,GAAGF;IAC1C,MAAMG,YAAWH,kBAAAA,MAAMG,QAAQ,YAAdH,kBAAkBvC;IACnC,IAAIQ,QAAQmC,MAAM,GAAG,GAAG,OAAO;QAAEC,SAAS;QAAS9B,QAAQN,OAAO,CAAC,EAAE;IAAC;IACtE,IAAIiC,SAAS,OAAO;QAAEG,SAAS;QAAS9B,QAAQ;IAAiB;IACjE,IAAIL,MAAMkC,MAAM,GAAG,GAAG,OAAO;QAAEC,SAAS;QAAW9B,QAAQL,KAAK,CAAC,EAAE;IAAC;IACpE,IAAI,CAACiC,SAASG,QAAQ,CAACL,OAAO;QAC5B,OAAO;YAAEI,SAAS;YAAW9B,QAAQ;QAAiB;IACxD;IACA,OAAO;QAAE8B,SAAS;QAAS9B,QAAQ;IAAY;AACjD;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,MAAMgC,oBAAoB,IAAG;AA0BpC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoDC,GACD,OAAO,SAASC,oBAAoBR,KAOnC;QACmBA;IAAlB,MAAMS,aAAYT,0BAAAA,MAAMU,gBAAgB,YAAtBV,0BAA0BO;IAC5C,MAAMI,UAAUX,MAAMY,mBAAmB;IACzC,IAAID,YAAYE,aAAaF,YAAY,MAAM;QAC7C,OAAO;YAAEG,SAAS;YAAWC,YAAY;YAAMxC,QAAQ;QAAiB;IAC1E;IACA,IAAI,OAAOoC,YAAY,YAAY,CAACK,OAAOC,QAAQ,CAACN,UAAU;QAC5D,OAAO;YACLG,SAAS;YACTC,YAAY;YACZxC,QAAQ;QACV;IACF;IACA,0EAA0E;IAC1E,2EAA2E;IAC3E,uCAAuC;IACvC,IAAIoC,WAAW,GAAG;QAChB,OAAO;YACLG,SAAS;YACTC,YAAY;YACZxC,QAAQ;QACV;IACF;IACA,MAAM2C,WAAWlB,MAAMmB,YAAY;IACnC,IAAI,OAAOD,aAAa,YAAY,CAACF,OAAOC,QAAQ,CAACC,aAAaA,YAAY,GAAG;QAC/E,OAAO;YAAEJ,SAAS;YAAWC,YAAY;YAAMxC,QAAQ;QAAoB;IAC7E;IACA,MAAMwC,aAAaG,WAAW,OAAOP;IACrC,uEAAuE;IACvE,yEAAyE;IACzE,gDAAgD;IAChD,IAAII,aAAa,CAACN,WAAW;QAC3B,OAAO;YAAEK,SAAS;YAAWC,YAAY;YAAMxC,QAAQ;QAAa;IACtE;IACA,OAAO;QACLuC,SAASC,aAAaN,YAAY,YAAY;QAC9CM;QACAxC,QAAQ;IACV;AACF;AAEA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAAS6C,2BACdC,aAAmD,EACnDlB,WAA8B1C,uBAAuB;IAErD,IAAI,CAAC4D,eAAe,OAAO,EAAE;IAC7B,IAAIA,cAAcf,QAAQ,CAAC,MAAM,OAAO,EAAE;IAC1C,MAAMgB,UAAU,IAAI7C,IAAI4C;IACxB,OAAOlB,SAASoB,MAAM,CAAC,CAACC,QAAU,CAACF,QAAQ5B,GAAG,CAAC8B,QAAQC,IAAI;AAC7D"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/webhook-delivery.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 * DID THE WEBHOOK ACTUALLY DO ANYTHING? (AGL-1954)\n *\n * Every signal we have about the billing webhook is a signal about the\n * REQUEST. Stripe records `delivery_success` off the status code. The\n * idempotency claim in `stripeEvents` records that the signature verified and\n * the handler was entered. `/api/health/billing` (AGL-1924) counts what\n * Stripe attempted and failed to deliver. All three read green for the one\n * failure this module exists to catch: **a handler that answers 200 and moves\n * nothing.**\n *\n * That is not hypothetical here. AGL-1798 is exactly it — `charge.refunded`\n * was never subscribed on the live destination, so AGL-1546's entitlement\n * revocation had no trigger, and every other indicator looked fine for as\n * long as it lasted. AGL-1551 is the same class from the other side: a week\n * of 100% rejected deliveries behind a green \"Active\" badge.\n *\n * ## The honest signal is an EFFECT, not a return code\n *\n * A check that asserts \"the handler ran\" is nearly as blind as the 200 —\n * the handler DID run in the AGL-1798 shape, it just had no work registered\n * for the event. So the route reports what it COMMITTED: an org's plan\n * mirrored, a `platformRevenue` row written, a refund stamped, an orphan\n * recorded, an audit row appended, a plugin claiming the event. Those are the\n * things whose absence is the bug.\n *\n * ## Three outcomes, and conflating the middle two is its own failure\n *\n * A binary \"did it write something\" would fire on every legitimately\n * irrelevant delivery — a tenant shopper's subscription carries no\n * `metadata.orgId` and correctly moves nothing on OUR side; a marketplace\n * refund resolves to no workspace customer and is correctly left to the\n * plugins; a `won` dispute nobody claimed reversed no money and correctly\n * wakes nobody. Alerting on those is alert fatigue, which ends with the alarm\n * muted and the real one lost inside it.\n *\n * So a branch that decides an event is not its business must SAY SO, by\n * name, through {@link WebhookEffectLedger.skip}. That is what separates:\n *\n * | outcome | meaning | alarm |\n * |-----------|----------------------------------------------------|-------|\n * | `acted` | something durable committed, or a plugin claimed it | no |\n * | `ignored` | a branch named a reason, or we never asked for this event type | no |\n * | `inert` | an event we deliberately subscribe to produced NEITHER | YES |\n *\n * `inert` is the whole point. Falling off the end of the dispatch with no\n * effect and no stated reason is precisely what a deleted write, an\n * unregistered plugin handler, or a renamed event type looks like from\n * inside the process — and it is the only one of the three that nothing else\n * in this system can see.\n *\n * ## Why the \"did we ask for it\" test is the required-events list\n *\n * Stripe delivers what the destination is subscribed to, and the destination\n * is created by `tools/scripts/setup-stripe.mjs` from `WEBHOOK_EVENTS`. An\n * event type outside that list reaching us is either a hand-added\n * subscription or a Connect delivery arriving at the platform destination —\n * unremarkable, and never our silent-drop bug, because we never claimed to\n * handle it. An event type INSIDE it is one we went out of our way to ask\n * Stripe for, so producing nothing from it is a contradiction by\n * construction.\n *\n * `REQUIRED_WEBHOOK_EVENTS` below is a copy of that list, and a copy is a\n * drift hazard — so `webhook-delivery.spec.ts` reads the `.mjs` and fails on\n * any divergence, in either direction. The alternative (moving the list\n * across the nx boundary out of `tools/`) is a wider change than this\n * warrants and is tracked on AGL-1948.\n *\n * Pure: no clock, no I/O, no Firestore. The route records, this decides, the\n * spec exercises every branch without a network.\n */\n\n/** What a single delivery turned out to be. */\nexport type WebhookDeliveryOutcome = 'acted' | 'ignored' | 'inert'\n\n/**\n * The event types the platform destination is subscribed to, mirroring\n * `WEBHOOK_EVENTS` in `tools/scripts/lib/stripe-webhook-health.mjs`.\n *\n * The list is the definition of \"we asked for this\", which is what makes a\n * no-op delivery of one of them a defect rather than a non-event. Kept in\n * the same order as the source so a diff between the two reads cleanly.\n */\nexport const REQUIRED_WEBHOOK_EVENTS: readonly string[] = [\n 'customer.subscription.created',\n 'customer.subscription.updated',\n 'customer.subscription.deleted',\n 'checkout.session.completed',\n 'invoice.finalized',\n 'invoice.paid',\n 'invoice.payment_failed',\n 'charge.refunded',\n 'charge.dispute.created',\n 'charge.dispute.closed',\n // AI overage charged as it accrues (AGL-3011), in the source list's order.\n 'invoice.voided',\n 'invoice.marked_uncollectible',\n 'customer.updated',\n 'payment_method.attached',\n 'payment_method.detached',\n // Fraud signals to staff (AGL-3356).\n 'radar.early_fraud_warning.created',\n 'review.opened',\n // Register card readers (AGL-3607).\n 'terminal.reader.action_succeeded',\n 'terminal.reader.action_failed',\n]\n\n/**\n * How the CONNECT destination identifies itself (AGL-1948), mirroring\n * `CONNECT_SCOPE_METADATA_KEY` / `CONNECT_SCOPE_METADATA_VALUE` in\n * `tools/scripts/lib/stripe-webhook-health.mjs`.\n *\n * Connected-account events are delivered only to a destination created with\n * `connect: true`, and Stripe's API does not report that flag back on the\n * endpoint object — so the destination is identified by metadata we set at\n * creation instead. Same copy-and-guard arrangement as the list above.\n */\nexport const CONNECT_SCOPE_METADATA_KEY = 'aglyn_scope'\nexport const CONNECT_SCOPE_METADATA_VALUE = 'connect'\n\n/**\n * What the Connect destination must carry, mirroring\n * `CONNECT_WEBHOOK_EVENTS` in the same script lib.\n *\n * `account.updated` is the whole point of it: AGL-1997's `syncConnectAccountStatus`\n * is what stops a merchant whose Stripe account was later restricted from\n * going on selling against a stale `stripeChargesEnabled`, with the SHOPPER\n * meeting the failure at payment time. Without this event the handler cannot\n * run — which is exactly the state AGL-2122 found and fixed.\n */\nexport const REQUIRED_CONNECT_WEBHOOK_EVENTS: readonly string[] = [\n 'account.updated',\n 'payout.failed',\n 'payout.paid',\n]\n\n/**\n * Does this Stripe endpoint object carry our Connect scope marker?\n *\n * Coverage for it reuses `unsubscribedRequiredEvents` with\n * `REQUIRED_CONNECT_WEBHOOK_EVENTS` as the `required` argument — the wildcard\n * and null handling are identical questions, and a second copy of that logic\n * is a second place for it to drift.\n */\nexport function isConnectWebhookEndpoint(endpoint: unknown): boolean {\n const metadata = (endpoint as { metadata?: Record<string, unknown> })?.metadata\n return metadata?.[CONNECT_SCOPE_METADATA_KEY] === CONNECT_SCOPE_METADATA_VALUE\n}\n\n/**\n * A per-delivery record of what actually committed.\n *\n * Deliberately append-only and stringly-typed: the reasons are read by a\n * human in a log line and by a spec, never switched on. Keeping it dumb is\n * what stops it growing into a second dispatch table that can disagree with\n * the real one.\n */\nexport interface WebhookEffectLedger {\n /** Durable things this delivery committed, in the order they landed. */\n readonly effects: readonly string[]\n /** Reasons a branch decided the event was none of its business. */\n readonly skips: readonly string[]\n /**\n * Record a committed effect. Call this AFTER the write it names, so a\n * throw between the two cannot claim an effect that never landed.\n */\n effect(name: string): void\n /**\n * Record a deliberate no-op and why. This is the difference between \"not\n * ours\" and \"broken\", and it is the half that prevents alert fatigue.\n */\n skip(reason: string): void\n}\n\nexport function createWebhookEffectLedger(): WebhookEffectLedger {\n const effects: string[] = []\n const skips: string[] = []\n return {\n effects,\n skips,\n effect(name: string) {\n effects.push(name)\n },\n skip(reason: string) {\n skips.push(reason)\n },\n }\n}\n\n/** Firestore methods that MUTATE. Everything else is a read or a builder. */\nconst WRITE_METHODS = new Set(['set', 'update', 'create', 'delete', 'add'])\n\n/**\n * Wrap a Firestore handle so every write it commits lands in the ledger.\n *\n * ## Why this exists rather than a `ledger.effect()` beside each write\n *\n * A hand-placed note next to a write is a check that CANNOT FAIL in the one\n * way that matters. Delete the write and leave the note, and the ledger\n * cheerfully reports an effect that never happened — which is the same\n * \"asserts its own literals\" defect the health probes are written to avoid.\n * The note has to be caused by the write, not merely adjacent to it.\n *\n * So the effect is recorded from INSIDE the call, after it resolves. A write\n * that throws records nothing (it did not commit); a write that is deleted\n * from the source records nothing (it is not called). There is no edit to\n * the handler that removes the work and keeps the signal.\n *\n * ## What it deliberately does not see\n *\n * Writes issued through a DIFFERENT Firestore handle — the shared\n * `tenant-data-admin` writers (`writeOrgBilling`, `notifyOrgAdmins`,\n * `notifyStaff`), which each open their own — and anything a plugin does\n * inside its own handler. Those are noted explicitly by the caller where\n * they are the only consequence of a branch, and the comments at those call\n * sites say so. The boundary is stated rather than hidden: this covers the\n * route's own writes, which is where the route's own bugs live.\n *\n * A Proxy rather than a hand-written facade because the wrapped object is\n * passed on to `updateExisting` and friends, which call methods this module\n * has never heard of. A facade would have to enumerate them and would\n * silently drop the ones it forgot.\n */\nexport function observeWrites<T extends object>(\n handle: T,\n ledger: WebhookEffectLedger,\n label = 'firestore',\n): T {\n return new Proxy(handle, {\n get(target, property, receiver) {\n const value = Reflect.get(target, property, receiver)\n if (typeof value !== 'function' || typeof property !== 'string') {\n return value\n }\n const method = property\n return (...args: unknown[]) => {\n const result = (value as (...a: unknown[]) => unknown).apply(\n target,\n args,\n )\n // `collection('orgs')` names the thing being written, so the ledger\n // reads `orgs.update` rather than `firestore.update`. Everything\n // else inherits the label it was reached through.\n const nextLabel =\n method === 'collection' && typeof args[0] === 'string'\n ? (args[0] as string)\n : label\n if (WRITE_METHODS.has(method)) {\n // Recorded on RESOLUTION. A rejected write committed nothing and\n // must not read as an effect.\n return Promise.resolve(result).then((settled) => {\n ledger.effect(`${nextLabel}.${method}`)\n return settled\n })\n }\n // Builders (`collection`, `doc`, `where`, `limit`) return objects\n // that can themselves be written through, so the wrapper follows\n // them. Thenables are returned bare: wrapping a promise would make\n // `then` look like a builder and recurse forever.\n if (\n result &&\n typeof result === 'object' &&\n typeof (result as { then?: unknown }).then !== 'function'\n ) {\n return observeWrites(result as object, ledger, nextLabel)\n }\n return result\n }\n },\n })\n}\n\nexport interface WebhookDeliveryVerdict {\n outcome: WebhookDeliveryOutcome\n /** One short phrase naming WHY, for the log line and the spec. */\n reason: string\n}\n\n/**\n * Classify one delivery.\n *\n * Order is the argument:\n *\n * 1. **Any effect, or any plugin claim, wins.** A delivery that both wrote\n * something and skipped something else did work; a partially-skipped\n * handler is not an idle one.\n * 2. **A named skip is an answer.** Reaching a branch that consciously\n * decided \"not ours\" is a handled event, not a dropped one.\n * 3. **An event type we never subscribed to is not our problem.** Nothing\n * promised to handle it.\n * 4. **Everything else is inert** — and by elimination that means: we asked\n * Stripe for this event, we received it, no handler claimed it, no branch\n * wrote anything, and no branch could say why.\n */\nexport function classifyWebhookDelivery(input: {\n type: string\n effects: readonly string[]\n skips: readonly string[]\n claimed: boolean\n /** Overridable so the spec can prove the rule rather than the list. */\n required?: readonly string[]\n}): WebhookDeliveryVerdict {\n const { type, effects, skips, claimed } = input\n const required = input.required ?? REQUIRED_WEBHOOK_EVENTS\n if (effects.length > 0) return { outcome: 'acted', reason: effects[0] }\n if (claimed) return { outcome: 'acted', reason: 'plugin-claimed' }\n if (skips.length > 0) return { outcome: 'ignored', reason: skips[0] }\n if (!required.includes(type)) {\n return { outcome: 'ignored', reason: 'not-subscribed' }\n }\n return { outcome: 'inert', reason: 'no-effect' }\n}\n\n/**\n * How long after Stripe CREATES an event its first delivery attempt may\n * plausibly take to reach us, in seconds. Mirrors `RETRY_LAG_SECONDS` in\n * `tools/scripts/lib/stripe-webhook-health.mjs`, under the same\n * read-the-`.mjs`-as-text drift guard as the lists above.\n *\n * Calibrated against the live account on 2026-08-18: the five deliveries that\n * succeeded first time landed in 1.0–3.7s; the one that did not —\n * `evt_1U49XtDYHP4psn7hA9VHPnZz`, whose three 400s ARE the three failures the\n * Stripe Dashboard was showing — landed 16,665s (4h 37m) late. Two minutes\n * sits three orders of magnitude clear of the healthy band and still inside\n * Stripe's first automatic retry interval, so it separates the two without\n * straddling either.\n */\nexport const RETRY_LAG_SECONDS = 120\n\n/**\n * What a delivery's lag says about the attempts BEFORE it.\n *\n * `unknown` is a first-class outcome, not a synonym for either answer. See\n * {@link classifyDeliveryLag} for why that matters more here than usual.\n */\nexport type WebhookDeliveryAttempt = 'first-attempt' | 'retried' | 'unknown'\n\n/** Why {@link classifyDeliveryLag} answered as it did. */\nexport type WebhookDeliveryLagReason =\n | 'measured'\n | 'created-absent'\n | 'created-not-a-number'\n | 'created-non-positive'\n | 'received-unusable'\n | 'clock-skew'\n\nexport interface WebhookDeliveryLagVerdict {\n attempt: WebhookDeliveryAttempt\n /** Seconds between `event.created` and our claim. Null unless `measured`. */\n lagSeconds: number | null\n reason: WebhookDeliveryLagReason\n}\n\n/**\n * DID THIS DELIVERY ONLY LAND ON A RETRY? (AGL-2039, the last arm of AGL-1948)\n *\n * ## The reconciliation this exists to close\n *\n * `/api/health/billing` scores EVENTS and `GET /v1/events?delivery_success=false`\n * is a TERMINAL-state filter: it selects events that are still pending or\n * failed EVERY attempt. An event that 400s three times and then succeeds on\n * the fourth reads back `delivery_success: true` and is, to that filter,\n * indistinguishable from one that succeeded immediately. The Stripe Dashboard\n * scores delivery ATTEMPTS, so the same hour reads 0% here and 30% there —\n * and both numbers are right. The three real failures were visible only in\n * the second reading, and each was a mirror that did not run when it should\n * have.\n *\n * The recoverable half of the attempt history is in our own data. The route\n * claims `stripeEvents/{id}` AFTER signature verification and DELETES the\n * claim when a handler throws, so the stamp always records the attempt that\n * actually got through. The distance from `event.created` to that stamp is\n * the delivery lag, and a lag of minutes means an earlier attempt failed.\n *\n * ## Why this is decided at WRITE time, not at read time\n *\n * The obvious implementation reads `receivedAt` off every claim document in\n * the window and subtracts. That turns a `.count()` aggregation — one\n * integer, zero documents read — into a document scan on a PUBLIC,\n * unauthenticated health endpoint polled every five minutes. Deciding here,\n * once, at the moment of the claim, lets the probe stay an aggregation over a\n * marker field, exactly like AGL-1954's `inertAtMs`.\n *\n * ## Absent, zero and unknown are THREE different things\n *\n * `strictNullChecks` is off repo-wide, so an absent `created` folds to falsy\n * and a naive `Number(event?.created ?? 0)` yields 0 — which subtracts to a\n * lag of ~1.7 BILLION seconds and reports every such delivery as a retry,\n * forever, on a perfectly healthy webhook. A monitoring signal that reds on\n * its own missing input is worse than no signal, because it is the thing that\n * gets muted. So each of those inputs is rejected by name:\n *\n * | input | attempt | reason |\n * |-----------------------------|------------|-------------------------|\n * | field absent / null | `unknown` | `created-absent` |\n * | a string, NaN, Infinity | `unknown` | `created-not-a-number` |\n * | `0` or negative | `unknown` | `created-non-positive` |\n * | our own stamp unusable | `unknown` | `received-unusable` |\n * | created in the FUTURE by more than the bar | `unknown` | `clock-skew` |\n *\n * A lag between `-threshold` and `+threshold` is `first-attempt`: sub-second\n * clock skew between Stripe's clock and ours is ordinary and is not evidence\n * of anything.\n *\n * Pure: no clock, no I/O. The caller supplies both timestamps.\n */\nexport function classifyDeliveryLag(input: {\n /** `event.created`, in unix SECONDS, exactly as Stripe sent it. */\n eventCreatedSeconds: unknown\n /** When we claimed the event, in unix MILLISECONDS. */\n receivedAtMs: unknown\n /** Overridable so the spec can prove the rule rather than the constant. */\n thresholdSeconds?: number\n}): WebhookDeliveryLagVerdict {\n const threshold = input.thresholdSeconds ?? RETRY_LAG_SECONDS\n const created = input.eventCreatedSeconds\n if (created === undefined || created === null) {\n return { attempt: 'unknown', lagSeconds: null, reason: 'created-absent' }\n }\n if (typeof created !== 'number' || !Number.isFinite(created)) {\n return {\n attempt: 'unknown',\n lagSeconds: null,\n reason: 'created-not-a-number',\n }\n }\n // Zero is the value an absent field folds to under the repo's null rules,\n // and no Stripe event was created at the epoch. Rejected by name so it can\n // never be mistaken for a measurement.\n if (created <= 0) {\n return {\n attempt: 'unknown',\n lagSeconds: null,\n reason: 'created-non-positive',\n }\n }\n const received = input.receivedAtMs\n if (typeof received !== 'number' || !Number.isFinite(received) || received <= 0) {\n return { attempt: 'unknown', lagSeconds: null, reason: 'received-unusable' }\n }\n const lagSeconds = received / 1000 - created\n // An event stamped in OUR future by more than the threshold is a clock\n // disagreement, not a fast delivery, and reporting it as a healthy first\n // attempt would be an answer we cannot support.\n if (lagSeconds < -threshold) {\n return { attempt: 'unknown', lagSeconds: null, reason: 'clock-skew' }\n }\n return {\n attempt: lagSeconds > threshold ? 'retried' : 'first-attempt',\n lagSeconds,\n reason: 'measured',\n }\n}\n\n/**\n * Which required events is a destination NOT subscribed to? (AGL-1948)\n *\n * The same blind spot from the configuration side, and the cheaper half of\n * it: a subscription removed by hand in the Stripe dashboard produces no\n * failed delivery, no rejected request and no inert one either — Stripe\n * simply stops sending, and every count on the health probe reads a\n * perfectly healthy zero. `charge.refunded` was missing from the live\n * endpoint for exactly this reason (AGL-1798) and the only thing that ever\n * noticed was a script nobody ran.\n *\n * `['*']` is Stripe's wildcard subscription and covers everything.\n *\n * Returns the names, sorted, so the probe body is stable and a diff between\n * two readings is meaningful.\n */\nexport function unsubscribedRequiredEvents(\n enabledEvents: readonly string[] | null | undefined,\n required: readonly string[] = REQUIRED_WEBHOOK_EVENTS,\n): string[] {\n if (!enabledEvents) return []\n if (enabledEvents.includes('*')) return []\n const enabled = new Set(enabledEvents)\n return required.filter((event) => !enabled.has(event)).sort()\n}\n"],"names":["REQUIRED_WEBHOOK_EVENTS","CONNECT_SCOPE_METADATA_KEY","CONNECT_SCOPE_METADATA_VALUE","REQUIRED_CONNECT_WEBHOOK_EVENTS","isConnectWebhookEndpoint","endpoint","metadata","createWebhookEffectLedger","effects","skips","effect","name","push","skip","reason","WRITE_METHODS","Set","observeWrites","handle","ledger","label","Proxy","get","target","property","receiver","value","Reflect","method","args","result","apply","nextLabel","has","Promise","resolve","then","settled","classifyWebhookDelivery","input","type","claimed","required","length","outcome","includes","RETRY_LAG_SECONDS","classifyDeliveryLag","threshold","thresholdSeconds","created","eventCreatedSeconds","undefined","attempt","lagSeconds","Number","isFinite","received","receivedAtMs","unsubscribedRequiredEvents","enabledEvents","enabled","filter","event","sort"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsEC,GAED,6CAA6C,GAG7C;;;;;;;CAOC,GACD,OAAO,MAAMA,0BAA6C;IACxD;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA,2EAA2E;IAC3E;IACA;IACA;IACA;IACA;IACA,qCAAqC;IACrC;IACA;IACA,oCAAoC;IACpC;IACA;CACD,CAAA;AAED;;;;;;;;;CASC,GACD,OAAO,MAAMC,6BAA6B,cAAa;AACvD,OAAO,MAAMC,+BAA+B,UAAS;AAErD;;;;;;;;;CASC,GACD,OAAO,MAAMC,kCAAqD;IAChE;IACA;IACA;CACD,CAAA;AAED;;;;;;;CAOC,GACD,OAAO,SAASC,yBAAyBC,QAAiB;IACxD,MAAMC,WAAYD,4BAAD,AAACA,SAAqDC,QAAQ;IAC/E,OAAOA,CAAAA,4BAAAA,QAAU,CAACL,2BAA2B,MAAKC;AACpD;AA2BA,OAAO,SAASK;IACd,MAAMC,UAAoB,EAAE;IAC5B,MAAMC,QAAkB,EAAE;IAC1B,OAAO;QACLD;QACAC;QACAC,QAAOC,IAAY;YACjBH,QAAQI,IAAI,CAACD;QACf;QACAE,MAAKC,MAAc;YACjBL,MAAMG,IAAI,CAACE;QACb;IACF;AACF;AAEA,2EAA2E,GAC3E,MAAMC,gBAAgB,IAAIC,IAAI;IAAC;IAAO;IAAU;IAAU;IAAU;CAAM;AAE1E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GACD,OAAO,SAASC,cACdC,MAAS,EACTC,MAA2B,EAC3BC,QAAQ,WAAW;IAEnB,OAAO,IAAIC,MAAMH,QAAQ;QACvBI,KAAIC,MAAM,EAAEC,QAAQ,EAAEC,QAAQ;YAC5B,MAAMC,QAAQC,QAAQL,GAAG,CAACC,QAAQC,UAAUC;YAC5C,IAAI,OAAOC,UAAU,cAAc,OAAOF,aAAa,UAAU;gBAC/D,OAAOE;YACT;YACA,MAAME,SAASJ;YACf,OAAO,CAAC,GAAGK;gBACT,MAAMC,SAAS,AAACJ,MAAuCK,KAAK,CAC1DR,QACAM;gBAEF,oEAAoE;gBACpE,iEAAiE;gBACjE,kDAAkD;gBAClD,MAAMG,YACJJ,WAAW,gBAAgB,OAAOC,IAAI,CAAC,EAAE,KAAK,WACzCA,IAAI,CAAC,EAAE,GACRT;gBACN,IAAIL,cAAckB,GAAG,CAACL,SAAS;oBAC7B,iEAAiE;oBACjE,8BAA8B;oBAC9B,OAAOM,QAAQC,OAAO,CAACL,QAAQM,IAAI,CAAC,CAACC;wBACnClB,OAAOT,MAAM,CAAC,GAAGsB,UAAU,CAAC,EAAEJ,QAAQ;wBACtC,OAAOS;oBACT;gBACF;gBACA,kEAAkE;gBAClE,iEAAiE;gBACjE,mEAAmE;gBACnE,kDAAkD;gBAClD,IACEP,UACA,OAAOA,WAAW,YAClB,OAAO,AAACA,OAA8BM,IAAI,KAAK,YAC/C;oBACA,OAAOnB,cAAca,QAAkBX,QAAQa;gBACjD;gBACA,OAAOF;YACT;QACF;IACF;AACF;AAQA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASQ,wBAAwBC,KAOvC;QAEkBA;IADjB,MAAM,EAAEC,IAAI,EAAEhC,OAAO,EAAEC,KAAK,EAAEgC,OAAO,EAAE,GAAGF;IAC1C,MAAMG,YAAWH,kBAAAA,MAAMG,QAAQ,YAAdH,kBAAkBvC;IACnC,IAAIQ,QAAQmC,MAAM,GAAG,GAAG,OAAO;QAAEC,SAAS;QAAS9B,QAAQN,OAAO,CAAC,EAAE;IAAC;IACtE,IAAIiC,SAAS,OAAO;QAAEG,SAAS;QAAS9B,QAAQ;IAAiB;IACjE,IAAIL,MAAMkC,MAAM,GAAG,GAAG,OAAO;QAAEC,SAAS;QAAW9B,QAAQL,KAAK,CAAC,EAAE;IAAC;IACpE,IAAI,CAACiC,SAASG,QAAQ,CAACL,OAAO;QAC5B,OAAO;YAAEI,SAAS;YAAW9B,QAAQ;QAAiB;IACxD;IACA,OAAO;QAAE8B,SAAS;QAAS9B,QAAQ;IAAY;AACjD;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,MAAMgC,oBAAoB,IAAG;AA0BpC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoDC,GACD,OAAO,SAASC,oBAAoBR,KAOnC;QACmBA;IAAlB,MAAMS,aAAYT,0BAAAA,MAAMU,gBAAgB,YAAtBV,0BAA0BO;IAC5C,MAAMI,UAAUX,MAAMY,mBAAmB;IACzC,IAAID,YAAYE,aAAaF,YAAY,MAAM;QAC7C,OAAO;YAAEG,SAAS;YAAWC,YAAY;YAAMxC,QAAQ;QAAiB;IAC1E;IACA,IAAI,OAAOoC,YAAY,YAAY,CAACK,OAAOC,QAAQ,CAACN,UAAU;QAC5D,OAAO;YACLG,SAAS;YACTC,YAAY;YACZxC,QAAQ;QACV;IACF;IACA,0EAA0E;IAC1E,2EAA2E;IAC3E,uCAAuC;IACvC,IAAIoC,WAAW,GAAG;QAChB,OAAO;YACLG,SAAS;YACTC,YAAY;YACZxC,QAAQ;QACV;IACF;IACA,MAAM2C,WAAWlB,MAAMmB,YAAY;IACnC,IAAI,OAAOD,aAAa,YAAY,CAACF,OAAOC,QAAQ,CAACC,aAAaA,YAAY,GAAG;QAC/E,OAAO;YAAEJ,SAAS;YAAWC,YAAY;YAAMxC,QAAQ;QAAoB;IAC7E;IACA,MAAMwC,aAAaG,WAAW,OAAOP;IACrC,uEAAuE;IACvE,yEAAyE;IACzE,gDAAgD;IAChD,IAAII,aAAa,CAACN,WAAW;QAC3B,OAAO;YAAEK,SAAS;YAAWC,YAAY;YAAMxC,QAAQ;QAAa;IACtE;IACA,OAAO;QACLuC,SAASC,aAAaN,YAAY,YAAY;QAC9CM;QACAxC,QAAQ;IACV;AACF;AAEA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAAS6C,2BACdC,aAAmD,EACnDlB,WAA8B1C,uBAAuB;IAErD,IAAI,CAAC4D,eAAe,OAAO,EAAE;IAC7B,IAAIA,cAAcf,QAAQ,CAAC,MAAM,OAAO,EAAE;IAC1C,MAAMgB,UAAU,IAAI7C,IAAI4C;IACxB,OAAOlB,SAASoB,MAAM,CAAC,CAACC,QAAU,CAACF,QAAQ5B,GAAG,CAAC8B,QAAQC,IAAI;AAC7D"}
|
|
@@ -60,6 +60,14 @@ export interface CoreOrgFeatureFlags {
|
|
|
60
60
|
/** A/B experiments (AGL-252); Business tier. */
|
|
61
61
|
abTesting?: boolean;
|
|
62
62
|
versioning?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Reusable components WITHOUT a count: Starter and above. Since AGL-3615 a
|
|
65
|
+
* component's create is counted against `componentsPerHost` alone — Free
|
|
66
|
+
* saves one — so this flag no longer decides whether a site may make one.
|
|
67
|
+
* It states that the allowance is unlimited, and a per-org override that
|
|
68
|
+
* turns it on lifts `componentsPerHost` with it, which is how an org
|
|
69
|
+
* granted the feature by contract before the count existed keeps it.
|
|
70
|
+
*/
|
|
63
71
|
reusableComponents?: boolean;
|
|
64
72
|
customDomain?: boolean;
|
|
65
73
|
/**
|
|
@@ -472,6 +480,14 @@ export interface CoreOrgEntitlements {
|
|
|
472
480
|
* is why a plan resolving to 0 still accepts submissions.
|
|
473
481
|
*/
|
|
474
482
|
formsPerHost?: number;
|
|
483
|
+
/**
|
|
484
|
+
* Reusable component DEFINITIONS per host — documents under
|
|
485
|
+
* `hosts/{hostId}/components` (AGL-3615). Free 1; every paid plan
|
|
486
|
+
* `UNLIMITED`, the allowance it had when components were a boolean
|
|
487
|
+
* feature. Refused at the create only, so a site holding more than its
|
|
488
|
+
* plan now includes keeps every component.
|
|
489
|
+
*/
|
|
490
|
+
componentsPerHost?: number;
|
|
475
491
|
/** Component-builder caps (AGL-99): host variables. */
|
|
476
492
|
variablesPerHost?: number;
|
|
477
493
|
/** Component-builder caps (AGL-99): host functions. */
|
|
@@ -1243,6 +1259,14 @@ export interface OrgCrmSettings {
|
|
|
1243
1259
|
* nobody asked for. Public mailbox domains never qualify either way.
|
|
1244
1260
|
*/
|
|
1245
1261
|
autoCreateCompanies?: boolean;
|
|
1262
|
+
/**
|
|
1263
|
+
* What a new contact, company, deal or task starts shared with
|
|
1264
|
+
* (AGL-3662), set apart from datasets and media. `'org'`: every site.
|
|
1265
|
+
* `'host'`: the site it came in on and its consent group. Unset reads the
|
|
1266
|
+
* org's `defaultResourceScope` — see `crmDefaultScopeOf` — and then
|
|
1267
|
+
* `'host'`.
|
|
1268
|
+
*/
|
|
1269
|
+
defaultRecordScope?: 'org' | 'host';
|
|
1246
1270
|
/**
|
|
1247
1271
|
* Per-site settings, keyed by host id (AGL-2618). On the ORG document
|
|
1248
1272
|
* rather than on each host document because the reader is the org-level
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/aglyn/src/lib/foundation/definitions/org-billing.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 * The org's billing/entitlement vocabulary (AGL-443 naming cleanup).\n *\n * HISTORY: these types were spelled `Tenant*` until AGL-444 — they\n * predate the organizations migration (AGL-232..238); the retired\n * `tenants/{uid}` collection's billing shape was mirrored ONTO the org\n * doc and the names came along. The alias is gone: everything here is\n * `Org*` and describes fields of `orgs/{orgId}`. The last persisted\n * tenant spellings (the `users.{uid}.tenants` map, Stripe\n * `metadata[tenantId]`, `host.tenantId`) were retired pre-launch in\n * AGL-445 — billing keys off `metadata[orgId]` now.\n *\n * Convention (see the docs-site glossary): \"organization/org\" is the\n * entity; \"workspace\" is the user-facing word for it; \"tenant\" is\n * reserved for the published-site runtime (`apps/tenant`,\n * `@aglyn/tenant-*` libs).\n */\n\nimport type { ITimestamp } from '@aglyn/shared-util-timestamp'\nimport type {\n AglynDocument,\n HostUid,\n OrgUid,\n UserUid,\n} from './platform.types'\n/*\n * The plugin half of the two composed shapes below (AGL-3124).\n *\n * Each is an EMPTY interface a plugin augments with `declare module`, so the\n * keys a plugin owns are part of `OrgEntitlements` and `OrgFeatureFlags`\n * without the core listing them. The AI credits, the CRM's one-to-one email\n * pace, the form-submission and workflow-run bands, the marketplace's take\n * rate and selling gate and commerce's three gated features are declared that\n * way, each in its plugin's `plan-entitlements` module (AGL-3080).\n *\n * `import type` and nothing else: TypeScript erases it, so the plugin-manager\n * module is not on this module's runtime graph and a published page that\n * reaches the billing types (through the foundation barrel it must not reach\n * anyway) carries nothing new.\n */\nimport type {\n PluginEntitlementFeatures,\n PluginEntitlementQuotas,\n} from '../../plugin-manager/plugin-entitlement-keys'\n\nexport type { OrgUid } from './platform.types'\n\n\n/** Hosted in master catalog */\n/**\n * SaaS subscription tiers (Tenant Billing & SaaS Plans, AGL-38..41).\n * Pricing v3 (2026-07) inserted `scale` between business and advanced to\n * fill the $139→$399 gap, and added `agency` above advanced for\n * high-volume multi-site orgs — see the Pricing Decision Log.\n *\n * `enterprise` (AGL-1118) is a REAL plan, not a display label: it tops the\n * ladder with unlimited capacity plus white-label and SSO, and it is the ONE\n * tier with no list price — it is staff-provisioned per deal (AGL-1110), never\n * self-serve. Surfaces that offer plans for sale iterate `SELF_SERVE_PLANS`,\n * which excludes it; surfaces that merely NAME the org's plan read\n * `PLAN_LABELS` and get \"Enterprise\" for free.\n */\nexport type OrgPlan =\n | 'free'\n | 'starter'\n | 'pro'\n | 'business'\n | 'scale'\n | 'advanced'\n | 'agency'\n | 'enterprise'\n\n/**\n * The CORE boolean feature gates per plan; quotas live beside them as\n * numbers. Read {@link OrgFeatureFlags} instead — this half exists so the\n * plan tables can stay exhaustive over the platform's own gates while a\n * plugin declares its own (AGL-3124).\n */\nexport interface CoreOrgFeatureFlags {\n /** A/B experiments (AGL-252); Business tier. */\n abTesting?: boolean\n versioning?: boolean\n reusableComponents?: boolean\n customDomain?: boolean\n /**\n * Send mail as a domain the CUSTOMER owns — `hello@acme.com` — by publishing\n * our SPF, DKIM and return-path records in their own zone.\n *\n * Distinct from `customDomain`, which is the site's public web address and\n * authorizes nothing about mail; and distinct from `whiteLabel`, which\n * replaces the Aglyn brand — product name, logo, colors, support URL,\n * console chrome, favicon — across every branded surface. Sending as your\n * own name is one narrow consequence of white-labeling rather than the\n * whole of it, so the two cannot share a flag without the wider capability\n * following the narrower one wherever it goes.\n *\n * It is also the CHEAP half of the sending model, which is why it sits low\n * on the ladder. A customer-owned domain costs the platform one provider\n * domain object and NOTHING in our own DNS zone, because the customer\n * publishes the records; the platform subdomain a site is issued costs a\n * provider slot, three records in our zone and a permanent place in the\n * re-verification sweep. Holding the free-to-us option behind the highest\n * paywall makes the expensive one the default at every tier that can send.\n *\n * Read through `checkEntitlement` at the two routes that write a sending\n * identity: the org's domain list, and the per-site selection.\n */\n customSendingDomain?: boolean\n /**\n * Hold a sending subdomain of the PLATFORM's own —\n * `hello@{label}.mail.aglyn.app` — provisioned for one site inside Aglyn's\n * mail apex.\n *\n * The EXPENSIVE half of the sending model, and the reason it is a separate\n * flag from `customSendingDomain`. A domain the customer owns costs one\n * provider domain object and nothing in our zone, because they publish the\n * records. One of these costs a provider slot, THREE records in our own\n * zone, and a permanent place in the re-verification sweep — per site,\n * forever. The two capabilities are decided against different resources, so\n * a shared flag would move a bounded one whenever the unbounded one is\n * re-cut.\n *\n * The flag is the SECOND of two conditions and never the only one. Nothing\n * provisions on entitlement alone: a merchant asks from the sending-identity\n * route, this says whether the org carries what they asked for, and the\n * provider ceiling still refuses beyond the account's allowance. A site\n * refused at any of the three sends on the shared pool rather than not at\n * all.\n *\n * Read through `checkEntitlement` at\n * `libs/aglyn/src/lib/app-utils/dedicated-sending-domain.ts`, which is the\n * only reader — the claim path in `host-sending-domain.ts` goes through it.\n */\n dedicatedSendingDomain?: boolean\n removeBranding?: boolean\n /** Schedule a version to publish at a date/time (tier above versioning). */\n scheduledPublishing?: boolean\n /** AI copy assist in the besigner (AGL-89). */\n aiAssist?: boolean\n /**\n * Generative building and automation (AGL-2896): pages, components,\n * layouts, templates, SEO, email, campaigns, A/B variants, analytics\n * insights, products, CRM records, onboarding and workflows produced by\n * the assistant rather than assembled by hand.\n *\n * Distinct from `aiAssist`, which is the chat guide, copy assist and\n * generate-section rung every tier from Pro up carries. This is the\n * expensive rung: a generated surface carries the node tree, the catalog\n * and the theme in and iterates, so it draws credits by the hundreds where\n * a question draws tens. No self-serve tier includes it; the Aglyn AI\n * add-on (`OrgSeatAddons.aiAddon`) switches it on and adds the credit band\n * that funds it, and `resolveOrgEntitlements` flips this flag together\n * with `aiAssist` when the add-on is present. Enterprise carries it in the\n * agreement.\n */\n aiGenerative?: boolean\n /** No-code workflow builder (AGL-101). */\n workflows?: boolean\n /** Datasets + repeatable components (AGL-102/103). */\n dataStore?: boolean\n /** Video/file uploads in the media manager (AGL-162). */\n videoMedia?: boolean\n /** Appointment bookings (AGL-159). */\n bookings?: boolean\n /**\n * THE CRM (AGL-2611): contacts, leads, companies, the deals pipeline,\n * tasks, reports and custom fields, the CRM automation steps, and the\n * `crm:*` REST resources.\n *\n * The Contacts section included (AGL-2851). Capture still writes a site's\n * audience into the contacts collection on every plan including Free,\n * banded by `contactsPerHost`, and exporting or erasing those people stays\n * on every plan in Settings → Privacy. What this flag gates is the CRM a\n * team works them in, and it is the upgrade motive from Free to the first\n * paid tier rather than a line on top of one: for the small-business buyer\n * the CRM is the reason to pick a platform over a page builder.\n *\n * Read by the console shell through the CRM extension's and its widgets'\n * `featureFlag` (so the shell refuses every CRM section and page before\n * one mounts), by the `crm/*` routes, by the automation executor before a\n * CRM step runs, by the REST dispatcher in front of the CRM resources,\n * contacts included, and, restated, by the Firestore rules on a client's\n * CRM writes. A per-org override on `entitlements.features.crm` works the\n * way every other flag's does.\n */\n crm?: boolean\n /**\n * SEQUENCES (AGL-2974): one-to-one, multi-step email sequences a rep sends\n * from their own connected mailbox, logged on the CRM's records. The key\n * below keeps the name the feature shipped under, because it is stored on\n * the org (AGL-3199).\n *\n * False on EVERY plan, Enterprise included. Which tiers carry sequences\n * and connected mailboxes, and at what caps, is a packaging decision that\n * has not been made, so no plan may claim it. An organization reaches it\n * only through the per-org override on `entitlements.features.outreach`.\n *\n * Read by the console shell through the Sequences extension's\n * `featureFlag`, by the plugin's routes, and, restated, by the Firestore\n * rules in front of the `outreach*` collections. The rules carry no plan\n * list for it because no plan grants it; when packaging lands, the plan\n * table and those rules change together.\n */\n outreach?: boolean\n /**\n * Basic presentational interactions (AGL-577): menu/drawer open-close,\n * element show/hide, class toggles, sticky nav, navigation, site\n * alerts. Included on ALL plans — pure client-side DOM with no server\n * cost. The `actions` flag below gates the powerful automation steps\n * (server dispatch, runJs, analytics, overlays, raw HTML).\n *\n * `true` on all eight tiers, and NO code gates on it — which makes it look\n * dead, and it has now been filed as dead once (AGL-2082). It is not.\n * `tools/marketing/build-pricing-tables.mts` reads its value to emit the\n * \"Interactions\" row on the public /pricing compare table, ticked on every\n * plan, and the Free plan card bullets it. That row is the claim that a\n * hover-to-open menu is not a paid feature — a real competitive statement,\n * and the reason `true` everywhere is a DECISION rather than a default.\n *\n * So: do not delete it as dead weight. Deleting it silently removes a\n * public pricing row while the Figma frames still carry it in all four\n * responsive variants, which is a pricing call and not a cleanup.\n */\n interactions?: boolean\n /** Event → action automation builder (AGL-148). */\n actions?: boolean\n /** Outbound/inbound webhooks (AGL-149). */\n webhooks?: boolean\n /** Customer REST API v1 + API keys (AGL-615); Business tier. */\n apiAccess?: boolean\n /** Whole-site export/backup + restore (AGL-163). */\n siteExport?: boolean\n /** Multilingual sites (AGL-164): locale variants + switcher. */\n multilingual?: boolean\n /** Event Calendar add-on (AGL-145); paid, not part of any base tier. */\n eventCalendar?: boolean\n /** URL redirects manager (AGL-154). */\n redirects?: boolean\n /** Per-screen traffic analytics (AGL-150). */\n screenAnalytics?: boolean\n /** CDN delivery + responsive image variants for media (AGL-175). */\n mediaCdn?: boolean\n /** Announcement bar + promotional popups (AGL-195/196). */\n marketingOverlays?: boolean\n /** Full storefront commerce: catalog, cart, checkout (AGL-278). */\n commerce?: boolean\n /** Console point-of-sale mode (AGL-312). */\n pos?: boolean\n /** Entitlement-gated screens/sections/video paywalls (AGL-309). */\n contentGating?: boolean\n /** Verified-buyer product reviews (AGL-324). */\n productReviews?: boolean\n /** Abandoned checkout recovery emails (AGL-323). */\n abandonedCart?: boolean\n /** Dropship supplier routing on paid orders (AGL-289). */\n dropshipRouting?: boolean\n /**\n * White-label the platform (White-Label Phase 1): replace the Aglyn brand\n * — product name, logo, colors, support URL, transactional email from-name\n * — with the org's own `brandingProfile` across every branded surface.\n * Agency ($799) and Enterprise carry it on the plan (AGL-1118); any other\n * tier needs a per-org `entitlements` override. Strictly broader than\n * `removeBranding`, which only drops the\n * \"Made with Aglyn\" badge on published sites; white-label REPLACES the\n * brand rather than merely hiding it. Every branded surface resolves the\n * effective brand through `resolveBrandingProfile` so it can never drift.\n */\n whiteLabel?: boolean\n /**\n * Enterprise SSO (AGL-1101): the org's console users sign in through the\n * org's own SAML/OIDC IdP, wired as a per-org GCIP tenant (`org.sso`).\n * Distinct from `whiteLabel` — an Agency org can have one without the other.\n * Carried by the `enterprise` plan (AGL-1118) and false on every other base\n * plan; a lower tier needs a per-org `entitlements` override, which is how\n * enterprise orgs provisioned before that plan existed still get it. Gates\n * the staff SSO-config card and the SSO sign-in path; a non-entitled org can\n * neither configure nor use SSO.\n */\n ssoEnabled?: boolean\n}\n\n/**\n * Boolean feature gates per plan, the platform's and its plugins' together.\n *\n * {@link CoreOrgFeatureFlags} is the platform's own set — what\n * `PLAN_ENTITLEMENTS` declares for every plan, and what\n * `Required<CoreOrgFeatureFlags>` keeps exhaustive.\n * {@link PluginEntitlementFeatures} is the plugin half (AGL-3124): a plugin\n * declares a gate of its own into it with `declare module`, and its answer\n * per plan comes from that plugin's `plan-entitlements` declaration or its\n * `registerPluginEntitlements` defaults rather than from a row core writes —\n * which is why a plugin's gate joins HERE and not above. Every reader keeps reading `OrgFeatureFlags`, and\n * `keyof OrgFeatureFlags` admits a plugin's key the moment it is declared.\n */\nexport interface OrgFeatureFlags\n extends CoreOrgFeatureFlags,\n PluginEntitlementFeatures {}\n\n/**\n * An org's white-label brand identity (White-Label Phase 1). Populated on\n * the org doc (`orgs/{orgId}.brandingProfile`) and applied ONLY when the org\n * carries the `whiteLabel` entitlement (Agency or Enterprise plan, or a\n * per-org override);\n * otherwise every surface falls back to the Aglyn defaults baked into\n * `resolveBrandingProfile`. Every field is optional — a partial profile\n * still resolves, with the Aglyn default filling each gap — so an agency can\n * set just a product name and from-name without supplying logos.\n */\nexport interface OrgBrandingProfile {\n /** Brand name shown in place of \"Aglyn\" (console chrome, emails, badges). */\n productName?: string\n /** Primary/full-color logo URL (light backgrounds, console chrome). */\n logoUrl?: string\n /**\n * The logo for DARK grounds (AGL-3406): the console chrome in dark mode and the\n * published site's attribution badge, which always sits on a dark pill.\n * Unset falls back to {@link logoUrl}.\n */\n logoDarkUrl?: string\n /** Favicon URL for branded surfaces. */\n faviconUrl?: string\n /**\n * The favicon for a browser whose tab strip is dark, emitted as a second\n * `<link rel=\"icon\">` under `(prefers-color-scheme: dark)`. Unset falls back\n * to {@link faviconUrl}.\n */\n faviconDarkUrl?: string\n /** Brand primary color as a CSS color (hex), e.g. `#1a73e8`. */\n primaryColor?: string\n /** Support/help destination linked from branded surfaces and emails. */\n supportUrl?: string\n /** Transactional email from-name (the display name before the address). */\n fromName?: string\n /** Logo URL specifically for the email header (often a hosted PNG). */\n emailLogoUrl?: string\n /** Custom console domain the agency serves the app on (Phase 4 wiring). */\n customConsoleDomain?: string\n}\n\n/**\n * An org's enterprise SSO configuration (AGL-1101, Phase 1). Applied ONLY when\n * the org carries the `ssoEnabled` entitlement. The actual IdP lives in a\n * per-org **GCIP tenant** (`tenantId`) with a SAML/OIDC **provider**\n * (`providerId`); this block is the org-doc mirror the console reads to route\n * sign-in and the staff card edits. A public `ssoDomains/{domain}` doc maps a\n * verified email domain → `{ orgId, tenantId, providerId }` so the\n * pre-auth sign-in page can resolve an SSO org without reading the org doc.\n *\n * Security: a domain reaches `domains[]` ONLY by passing DNS TXT verification\n * (AGL-1210). This is the account-takeover guard, and it is the whole reason\n * self-serve is safe: without it an org could claim a domain it does not own,\n * `ssoDomains/{domain}` would route that domain's sign-ins to its IdP, and it\n * would intercept another company's logins. Claims in flight live in\n * `orgs/{orgId}/ssoDomains/{domain}` (see `OrgSsoDomainClaim`) and are NOT\n * governed until verified.\n *\n * `enforced` blocks password/social login for the governed domains, so it is a\n * lockout risk and is rehearsed (`previewSsoEnforcement`) before it is applied.\n */\nexport interface OrgSsoConfig {\n /** GCIP tenant id that carries this org's IdP provider. */\n tenantId: string\n /** GCIP provider id, e.g. `saml.aglyn-workspace` or `oidc.acme`. */\n providerId: string\n /** IdP protocol. Phase 1 ships SAML; OIDC is Phase 2. */\n protocol: 'saml' | 'oidc'\n /** Human label for the IdP (shown on the SSO button + staff card). */\n displayName?: string\n /**\n * Email domains routed to this IdP (lowercased, no `@`). A domain is added\n * here only after its claim passes DNS TXT verification, and removed the\n * moment re-verification fails — so membership of this array IS the\n * \"ownership proven\" statement. Never write to it from anywhere but the\n * verification path.\n */\n domains: string[]\n /**\n * Retained for `sso-jit`, which gates on it. Kept in lockstep with\n * `domains.length > 0` by the verification path; it was a staff-attested\n * boolean before AGL-1210 and is now derived, never asserted by a human.\n */\n domainVerified: boolean\n /**\n * SAML metadata the CUSTOMER supplies about their IdP. Stored so the pool's\n * provider config can be rebuilt or re-applied without asking again. The\n * X.509 certificate is a public signing certificate, not a secret.\n */\n idp?: {\n entityId: string\n ssoUrl: string\n certificates: string[]\n }\n /**\n * Require SSO for the governed domains — disables password/social login for\n * them (Phase 2). Phase 1 keeps this false so users keep a fallback.\n */\n enforced: boolean\n /** Lifecycle: `configuring` (not live), `active`, or `disabled`. */\n status: 'configuring' | 'active' | 'disabled'\n /**\n * Uid + time of the last config change. Since AGL-1210 this is normally an\n * ORG ADMIN, not staff — the flow is self-serve end to end.\n */\n configuredBy?: string\n configuredAt?: ITimestamp\n}\n\n/**\n * A pending or proven claim on one email domain (AGL-1210), stored at\n * `orgs/{orgId}/ssoDomains/{domain}`.\n *\n * A subcollection rather than a map on the org doc because domains contain\n * dots, and a dotted key in a Firestore map is read as a nested field path by\n * every update helper — writing `sso.domainClaims[\"acme.com\"]` would silently\n * create `{acme: {com: …}}`. A document id has no such ambiguity.\n *\n * The claim is deliberately worthless on its own: holding one grants nothing.\n * Only `verified` moving to true adds the domain to `sso.domains`, and only\n * that array is consulted at sign-in.\n */\nexport interface OrgSsoDomainClaim {\n /** The domain being claimed (lowercased, no `@`); mirrors the document id. */\n domain: string\n /**\n * Random value the org must publish as a DNS TXT record at\n * `_aglyn-challenge.<domain>`. Per org+domain, so two orgs claiming the same\n * domain get different tokens and neither can pass on the other's record.\n */\n token: string\n /** True once a DNS lookup has actually seen `token` at the challenge host. */\n verified: boolean\n createdAt: ITimestamp\n verifiedAt?: ITimestamp\n /** Last lookup attempt, successful or not — drives re-verification. */\n lastCheckedAt?: ITimestamp\n /**\n * TXT records seen on the last FAILED lookup. Shown back to the customer,\n * because \"no record found\" and \"found the wrong value\" are different\n * mistakes and the fix differs.\n */\n lastRecords?: string[]\n}\n\n/**\n * Effective limits/gates for a tenant. Plan defaults come from\n * `PLAN_ENTITLEMENTS` (versioned with the app); per-tenant overrides can be\n * stored on the tenant doc and win over the plan defaults.\n *\n * The CORE half. Read {@link OrgEntitlements} instead: it is this plus\n * whatever the plugins declare. The keys spelled out below predate the seam\n * and move to their plugins with the rest of AGL-3080; every NEW key is\n * declared by whoever owns it.\n */\nexport interface CoreOrgEntitlements {\n hostLimit?: number\n screensPerHost?: number\n sharedLayoutsPerHost?: number\n /** Saved templates per host (AGL-666) — includes marketplace downloads. */\n templatesPerHost?: number\n // NOTE (AGL-658): \"add-on\" means two unrelated things in this codebase.\n // `seatAddons` below are BILLING capacity — extra managers, hosts, seats —\n // surfaced in the UI as \"plan add-ons\". The marketplace sense (installed\n // plugins, the `orgAddons` slot) is a different concept entirely. The\n // marketplace owns the bare word; billing copy always qualifies it.\n // Firestore and Stripe lookup keys stay as they are — they are persisted.\n storagePerHostMb?: number\n /**\n * RETIRED (AGL-2133). Enforced by nothing and unreachable by measurement —\n * see `RETIRED_ENTITLEMENT_KEYS`. It stays on the TYPE because live org\n * documents still carry staff overrides of it and `OrgEntitlements` is the\n * shape those documents are read through; `resolveOrgEntitlements` drops\n * it, and no plan declares it any more, so nothing can resolve a value.\n * @deprecated\n */\n totalSiteSizeMb?: number\n /**\n * Included per-site COLLABORATOR seats (`hosts/{id}/members`,\n * viewer/editor/admin) — console teammates scoped to one site, not\n * end-user member accounts (`siteMembers`), which are unlimited on\n * every plan (AGL-888/889). Legacy key name; persisted, do not rename.\n */\n membersPerHost?: number\n /** Seat model (AGL-112): included tenant-manager seats. */\n managersPerOrg?: number\n /** Hard seat caps incl. purchased addons; beyond these, upgrade the plan. */\n maxManagersPerOrg?: number\n /** Hard per-site collaborator cap incl. addons (see `membersPerHost`). */\n maxMembersPerHost?: number\n bandwidthGb?: number\n /**\n * Saved form DEFINITIONS per host — documents under\n * `hosts/{hostId}/forms`, the entity a submission's `formId` points at.\n *\n * Not the same dimension as `formSubmissionsPerMonth`, and the two are\n * routinely confused: this is how many distinct intake forms an author may\n * build, that is how many replies the site may accept. Only the second is\n * tiered. This one resolves to the same `FORMS_PER_HOST_CEILING` on every\n * plan that has the form entity at all — it bounds a collection rather than\n * selling a tier — and exists as an entitlement so that `checkQuota` can\n * refuse a create, and so a contract can override one org's number.\n *\n * Counts the CATALOG, never the traffic. A `Form` node drawn on a page and\n * left unbound submits without a definition and spends nothing here, which\n * is why a plan resolving to 0 still accepts submissions.\n */\n formsPerHost?: number\n /** Component-builder caps (AGL-99): host variables. */\n variablesPerHost?: number\n /** Component-builder caps (AGL-99): host functions. */\n functionsPerHost?: number\n /** Workflow builder cap (AGL-99/101). */\n workflowsPerHost?: number\n /** Bookable services per host (AGL-159). */\n servicesPerHost?: number\n /** Redirect rules per host (AGL-154). */\n redirectsPerHost?: number\n /**\n * The CRM RECORDS band (AGL-197, widened in AGL-2611): contacts, companies\n * and deals, counted together across the org. The persisted key still says\n * \"contacts\" because it is written on live org documents as a staff\n * override and read back through this type; every customer surface says\n * \"CRM records\". A tier with an `extraContactsUsdPer1k` rate meters past\n * it; a tier with none refuses the next record. Free has no CRM\n * (AGL-2851), so on Free the record its band refuses is a capture. Tasks\n * and activities are not counted — see `CRM_ACTIVITIES_PER_RECORD_CEILING`.\n */\n contactsPerHost?: number\n /**\n * CAMPAIGN emails sendable per calendar month (AGL-161), and campaign\n * emails only (AGL-1438).\n *\n * Transactional mail — password resets, invites, order confirmations,\n * booking reminders, workflow notifications — is never refused by this cap\n * at any tier. It still COUNTS toward the org's email cost meter; it simply\n * cannot be blocked, because a quota that can drop a password reset locks\n * somebody out of their own account and a dropped order confirmation reads\n * to the buyer as a failed order.\n *\n * The name is the narrow thing on purpose. A cap that means something\n * narrower than it says is how AGL-1438 came to exist, so every surface\n * that shows this number says \"campaign\".\n *\n * Sized against shared-domain reputation, which is a different principle\n * from the one behind `crmEmailsPerDay` — see there for why the two ladders\n * are not meant to track each other.\n */\n emailSendsPerMonth?: number\n /** Action runs per calendar month (AGL-148). */\n actionRunsPerMonth?: number\n /** Included customer REST API requests per calendar month (AGL-634);\n * Beyond it, metered overage per 1,000 where the plan prices it. Only\n * Business/Advanced carry `apiAccess`, so lower tiers are 0. */\n apiRequestsPerMonth?: number\n /** Dynamic data caps — org-scoped (AGL-239/240): datasets are shared\n * by every host in the org, so counts and size meter per org. */\n datasetsPerOrg?: number\n /** Hard dataset cap incl. addons (AGL-132/240); beyond it, upgrade. */\n maxDatasetsPerOrg?: number\n recordsPerDataset?: number\n /** Included aggregate dataset storage (MB) across the org (AGL-240);\n * Beyond it, metered overage per GB where the plan prices it. */\n dataStorageMbPerOrg?: number\n /** @deprecated Legacy host-keyed override (pre-AGL-240); resolved into\n * `datasetsPerOrg` by `resolveOrgEntitlements`. */\n datasetsPerHost?: number\n /** @deprecated Legacy host-keyed override (pre-AGL-240); resolved into\n * `maxDatasetsPerOrg` by `resolveOrgEntitlements`. */\n maxDatasetsPerHost?: number\n /** Catalog products per host (AGL-278). */\n productsPerHost?: number\n /** Inventory locations per host (AGL-286). */\n inventoryLocations?: number\n /** Concurrent POS registers (AGL-312); add-ons raise it (AGL-329). */\n posRegisters?: number\n /** Platform fee % on physical storefront sales (Connect app fee). */\n transactionFeePhysicalPct?: number\n /** Platform fee % on digital storefront sales (Connect app fee). */\n transactionFeeDigitalPct?: number\n features?: OrgFeatureFlags\n /**\n * A staff PLAN COMP (AGL-3034) — see `OrgPlanComp`. Not a quota: the\n * resolver's numeric loop skips it, and `ResolvedOrgEntitlements` omits\n * it. Read through `readOrgPlanComp`, never directly.\n */\n planComp?: OrgPlanComp\n}\n\n/**\n * Effective limits and gates for a tenant, the platform's and its plugins'\n * together (AGL-3124).\n *\n * {@link CoreOrgEntitlements} is what `PLAN_ENTITLEMENTS` declares per plan\n * and what `ResolvedOrgEntitlements` keeps exhaustive.\n * {@link PluginEntitlementQuotas} is the plugin half: a plugin declares a\n * key of its own into it with `declare module`, and its figure per plan comes\n * from that plugin's `plan-entitlements` declaration, compiled into\n * `PLAN_ENTITLEMENTS` (AGL-3080), widened by any seat add-on it declares. So\n * `resolveOrgEntitlements` carries a plugin's band, and `keyof\n * OrgEntitlements` admits its key, without this file naming the plugin's\n * domain.\n */\nexport interface OrgEntitlements\n extends CoreOrgEntitlements,\n PluginEntitlementQuotas {}\n\n/**\n * A plan staff GRANTED a workspace that no live subscription pays for\n * (AGL-3034), stored at `orgs/{orgId}.entitlements.planComp`.\n *\n * ## Why a marker of its own, and not the stored `plan`\n *\n * `plan: 'pro'` beside `billingStatus: 'canceled'` is what EVERY customer who\n * canceled can look like: Stripe's mirror is not always walked back to\n * `free`, and a staff override used to write `plan` directly. So a stored\n * paid plan must never outrank a dead subscription — `resolveEffectivePlan`\n * reads it as Free, which is AGL-247's rule and stays it. A comp is a\n * different fact with a different writer: only `/api/admin/org-override`\n * writes this, audited, with a reason. Nothing reads a comp out of `plan`.\n *\n * ## When it applies\n *\n * Only while the subscription is dead or absent — `resolvePlanComp`. A live\n * subscription (and any status the resolver does not read as dead) governs\n * the plan, and the comp waits, dormant, until staff remove it or the\n * subscription ends.\n *\n * ## Why under `entitlements`\n *\n * It sits beside the other staff overrides, on the one map the override\n * route already owns and the rules already deny to every client, staff\n * included (AGL-1795). A top-level key would have needed a new entry in all\n * three of the org rule's deny-lists, in a rules file at its size limit.\n *\n * ## What it is NOT\n *\n * Not a sale. A comp bills nothing, so every metered band on it is a wall —\n * `resolvePlanPricing` — and it is never a card on file or a paying\n * subscription: `isBillingSubscription` stays false, and the AI overage\n * charge path has no rate to bill it at.\n *\n * ## Capped or uncapped (AGL-3049)\n *\n * A comp is CAPPED unless staff lift it: every band is its plan's figure, a\n * hard limit, and a per-org quota override beside the comp raises one band.\n * An UNCAPPED comp (`uncapped: true`) reads every band and quota as\n * unlimited while it is in force — the shape an internal workspace needs —\n * and still bills nothing. The flag is part of the grant: the same route\n * writes it with its own reason and audit row, removing the comp removes it,\n * and a live subscription ignores it along with the rest of the comp.\n *\n * The route states it on every grant, `false` included. The org write is a\n * merge, which writes a nested map key by key, so a capped grant that left\n * the key out would keep the `true` of the uncapped comp it replaced.\n *\n * Typed loosely where foundation cannot import the vocabulary: `reason` is\n * an `OrgOverrideReasonCode` (`app-utils/org-override-reason`), narrowed by\n * `readOrgPlanComp`.\n */\nexport interface OrgPlanComp {\n /** The plan granted. A paid plan; a comp of `free` is not a comp. */\n plan: OrgPlan\n /**\n * Every band and quota reads as unlimited while the comp is in force\n * (AGL-3049). Only a literal `true` lifts them; absent means capped.\n */\n uncapped?: boolean\n /** Why, from the override's fixed reason set (AGL-1652). */\n reason: string\n /** Staff-only rationale; explicit `null` when none was given. */\n note: string | null\n /** The staff uid that granted it. */\n grantedBy: string\n /** When it was granted — the server's clock. */\n grantedAt: ITimestamp | null\n}\n\n/**\n * Paid addon quantities (AGL-112/524) purchased on top of the plan's\n * included allowances, billed as items on the org's Stripe subscription.\n * Seat/dataset kinds resolve as `included + purchased` clamped to the\n * plan's hard max — beyond the max the org must upgrade. Purchases only\n * count while the subscription is alive (they bill on it); staff grants\n * live on `entitlements` overrides instead, so the two never collide.\n */\nexport interface OrgSeatAddons {\n /** Extra tenant-manager seats. */\n managers?: number\n /**\n * Extra per-site collaborator seats. Legacy key name (AGL-888): the Stripe\n * price env suffix is still `EXTRA_MEMBER` and existing org docs carry this\n * key — persisted, do not rename.\n *\n * A POOL, not a raise (AGL-2439) — the same correction `posRegisters`\n * received in AGL-1775, applied to the key that never got it.\n * `membersPerHost` is enforced PER SITE, so folding this org-wide quantity\n * into the org-level entitlement handed every site the whole purchase: one\n * extra collaborator seat bought 20 collaborators on a 20-site org. Since\n * AGL-2439 the quantity here is the size of an org-level pool and\n * `collaboratorAllocations` says which site each purchased seat is assigned\n * to. Nothing reads this as a per-site number; use\n * `checkHostCollaboratorQuota` / `resolveHostCollaboratorCap`.\n */\n members?: number\n /** Extra org datasets (AGL-132/240); billed monthly per dataset. */\n datasets?: number\n /** Extra sites beyond the plan's `hostLimit` (AGL-68/524). */\n hosts?: number\n /**\n * Extra POS registers beyond the plan's `posRegisters` (AGL-329/524).\n *\n * A POOL, not a raise (AGL-1775). `posRegisters` is enforced PER SITE, so\n * folding this quantity into the org-level entitlement handed every site\n * the whole purchase — one $89/mo register bought 20 registers on a\n * 20-site org. Since AGL-1775 the quantity here is the size of an org-level\n * pool and `registerAllocations` says which site each purchased seat is\n * assigned to. Nothing reads this as a per-site number; use\n * `resolveHostRegisterCap`.\n */\n posRegisters?: number\n /** Event Calendar org-wide toggle, 0/1 (AGL-145/524). */\n eventCalendar?: number\n /**\n * Aglyn AI org-wide toggle, 0/1 (AGL-2896). One purchase covers every\n * host in the org, the way Event Calendar does: `resolveOrgEntitlements`\n * reads any quantity >= 1 as one, adds the plan's\n * `AI_ADDON_CREDITS_PER_MONTH` band to `assistCreditsPerMonth`, and\n * switches `features.aiGenerative` and `features.aiAssist` on. Priced per\n * plan at `PlanPricing.aiAddonMonthlyUsd`; `null` there means the plan\n * does not sell it.\n */\n aiAddon?: number\n}\n\n/**\n * Which SITE each purchased POS register seat is assigned to (AGL-1775):\n * `{ [hostId]: seats }`, drawn from the org-level pool\n * `seatAddons.posRegisters`.\n *\n * WHY IT LIVES ON THE ORG DOC. The pool is org-level and the entitlement it\n * modifies is resolved from this same document, so a caller that can resolve\n * entitlements at all already holds the allocation — there is no second read\n * to fail and no separate loading state in which a host could resolve to\n * something other than its plan cap. That property is the point: the\n * `checkQuota(undefined)` = Free-tier lesson inverted, where an absent\n * allocation must mean the PLAN's cap and never the pooled total.\n *\n * AN ENTITLEMENT INPUT, and therefore Admin-SDK-only — it is denied to every\n * client in `cloud/firebase-firestore.rules` alongside `seatAddons` and\n * `entitlements`. A client that could write this could assign itself the\n * whole pool on every site, which is the defect AGL-1775 exists to close.\n *\n * A host id absent from the map holds ZERO purchased seats and resolves to\n * the plan cap alone. Deleting a site deletes its key, which returns its\n * seats to the pool — the pool is `seatAddons.posRegisters` minus the sum of\n * this map, so a released key is available capacity by arithmetic rather\n * than by a separate counter that could drift.\n */\nexport type OrgRegisterAllocations = Record<string, number>\n\n/**\n * Which SITE each purchased COLLABORATOR seat is assigned to (AGL-2439):\n * `{ [hostId]: seats }`, drawn from the org-level pool `seatAddons.members`.\n *\n * The same object as `OrgRegisterAllocations` and for the same reasons —\n * see that type's block, all of which applies verbatim. It is a distinct type\n * because it is a distinct field on the document and the two pools are bought\n * separately; nothing may read one as the other.\n *\n * AN ENTITLEMENT INPUT, and therefore Admin-SDK-only — denied to every client\n * in `cloud/firebase-firestore.rules` alongside `seatAddons`,\n * `registerAllocations` and `entitlements`. A client that could write this\n * could assign itself the whole pool on every site, which is the defect the\n * pool exists to close.\n *\n * A host id absent from the map holds ZERO purchased seats and resolves to\n * the plan cap alone. Deleting a site deletes its key, returning its seats to\n * the pool by arithmetic rather than by a counter that could drift.\n *\n * ONE THING DIFFERS FROM REGISTERS and it is deliberate: collaborator seats\n * clamp to the plan's `maxMembersPerHost` band, because that is how they are\n * sold. Assigning ten pool seats to a Starter site cannot lift it past ten —\n * `resolveHostCollaboratorCap` applies the clamp, so the console must not\n * present an assignment past the band as capacity.\n */\nexport type OrgCollaboratorAllocations = Record<string, number>\n\n/**\n * The monthly storage-overage spend cap an org CHOSE for itself (AGL-1886,\n * corrected 2026-08-18).\n *\n * The ceiling on metered spend is the END USER's control: it exists to prevent\n * a surprise, not to gate the product on a customer's acknowledgement.\n *\n * So this document is **absent by default and absent is the normal state**.\n * Storage past a metered plan's included band bills without it; the customer\n * is warned by `usage-alerts` on approach and at the band. Writing a cap here\n * is a customer opting IN to being stopped, and it is the only thing that can\n * refuse an upload on a plan that meters.\n *\n * NOT a consent record. As an acknowledgement that must exist before a paying\n * org may store a byte past its band, it costs revenue and blocks customers\n * without preventing any bill — `report-usage` bills stored bytes and never\n * reads this document.\n *\n * Still an ENTITLEMENT INPUT in the security sense, in the other direction: a\n * member who could raise their own `capUsd` could raise their own spend\n * ceiling, so the rules deny it to every client and only\n * `/api/billing/storage-overage` (Admin SDK, `billing.manage`) writes it.\n *\n * @see apps/console/utils/storage-overage.ts for the model and what still\n * hard-bands.\n */\nexport interface OrgStorageOverage {\n /**\n * The monthly storage-overage spend the org will not go past. Present means\n * capped; absent means uncapped, which is the default. A present but\n * malformed value resolves to `STORAGE_CAP_FALLBACK_USD` rather than to \"no\n * cap\" — a customer who asked for a ceiling must not be billed past one\n * because the stored number was corrupt.\n */\n capUsd?: number\n /** When the cap was set, for the audit trail. */\n capSetAt?: ITimestamp | null\n /** The uid that set it. */\n capSetBy?: string | null\n /**\n * LEGACY (pre-2026-08-18): the acknowledged-consent pair. Read only, and\n * honoured as a cap of `monthlyCeilingUsd` so a ceiling somebody typed is\n * not silently raised. Never written again.\n */\n acknowledgedAt?: ITimestamp | null\n /** @deprecated legacy consent field; see `acknowledgedAt`. */\n acknowledgedBy?: string | null\n /** @deprecated legacy bound; read as a cap. See `capUsd`. */\n monthlyCeilingUsd?: number\n}\n\n/**\n * The org's own answer to what happens when Aglyn Assist reaches its included\n * band (AGL-2653).\n *\n * Absent by default, and absent is the normal state: assist past the band is\n * SOLD — `reserveAssistMessage` keeps reserving and `report-usage` bills the\n * credits past the band at `PLAN_PRICING.extraAssistCreditsUsdPer1k`. Writing\n * `hardCap: true` is a customer opting IN to being stopped at the band\n * instead, which is what the gate did for every org before the overage was\n * sold. The shape mirrors `OrgStorageOverage`, the same control on storage,\n * for the same reason: a ceiling on metered spend is the END USER's control,\n * offered rather than imposed.\n *\n * An ENTITLEMENT INPUT in the security sense, in both directions at once. A\n * client that could clear it would lift a ceiling the org chose; one that\n * could set it on another org would switch that org's assistant off at the\n * band. So the rules deny it to every client and only\n * `/api/billing/assist-overage` (Admin SDK, `billing.manage`) writes it. Read\n * through `resolveAssistHardCap` in the AI plugin's `usage/assist-credits`,\n * never directly.\n *\n * `report-usage` never reads this map — it bills whatever landed past the\n * band, the way storage bills stored bytes. With the cap on that is at most\n * the one exchange that crossed the line, because an exchange's cost is known\n * only after it is answered; with it off, it is the overage the org chose to\n * buy. Reading the switch at sweep time instead would let a flip on the 1st\n * erase a month's overage, the AGL-2399 shape on a different meter.\n *\n * On a plan whose `extraAssistCreditsUsdPer1k` is `null` the switch changes\n * nothing: there is no rate to sell past the band at, so the band stays the\n * wall it always was, on or off.\n *\n * ## The dollar ceiling beside the switch (AGL-2898)\n *\n * `capUsd` is the second control, and it answers a different question. The\n * switch asks \"sell past the band at all?\"; the ceiling asks \"and if so, how\n * much?\" — a monthly figure in USD of OVERAGE, priced at the plan's rate, past\n * which the assistant refuses for the rest of the month. It is the\n * `storageOverage.capUsd` of this meter: optional, absent by default, and the\n * customer's own number rather than a band the plan sold them. Read through\n * `resolveAssistOverageCapUsd`, and enforced by `assistOverageCapReached`\n * inside the same reservation transaction as the band.\n *\n * The two are independent. With the switch on the ceiling is never reached,\n * because nothing past the band is ever sold; with the switch off and no\n * ceiling the overage is open-ended, bounded only by the plan's message cap.\n * On a plan with no rate the ceiling, like the switch, changes nothing:\n * overage on such a plan prices to zero and zero never reaches a ceiling.\n */\nexport interface OrgAssistOverage {\n /** True stops assist at the included band; absent or false sells past it. */\n hardCap?: boolean\n /** When the switch was last written, for the audit trail. */\n hardCapSetAt?: ITimestamp | null\n /** The uid that wrote it. */\n hardCapSetBy?: string | null\n /**\n * The month's ceiling on AI overage, in USD at the plan's rate. `null` or\n * absent is no ceiling; anything that is not a finite positive number reads\n * as none too, so a corrupt value cannot become a wall of `NaN`.\n */\n capUsd?: number | null\n /** When the ceiling was last written or cleared, for the audit trail. */\n capSetAt?: ITimestamp | null\n /** The uid that wrote it. */\n capSetBy?: string | null\n}\n\n/**\n * A discount applied to the org's OWN Aglyn subscription (AGL-1105) — a\n * comped enterprise deal or a redeemed coupon, mirrored from a Stripe coupon\n * so `orgMonthlyRevenueUsd` can report net-of-discount MRR without a Stripe\n * round-trip. Exactly one of `percentOff` / `amountOffUsd` is set, matching\n * the Stripe coupon it points at (Stripe coupons are one or the other).\n * DISTINCT from a storefront/customer discount — this is Aglyn's own bill.\n */\nexport interface OrgDiscount {\n /** The Stripe coupon id (`co_…`) applied to the subscription. */\n couponId: string\n /** The Stripe promotion code id (`promo_…`), when the coupon has a code. */\n promotionCodeId?: string\n /** The human redemption code (e.g. `LAUNCH25`), when one exists. */\n code?: string\n /** Percentage off, 0–100 (mutually exclusive with `amountOffUsd`). */\n percentOff?: number\n /** Fixed USD off per invoice (mutually exclusive with `percentOff`). */\n amountOffUsd?: number\n /** The staff uid that applied it (audit trail; self-serve reads `system`). */\n appliedBy: UserUid\n /** Free-text why (the enterprise deal, the promotion) — staff-only. */\n reason?: string\n appliedAt: ITimestamp\n}\n\nexport interface OrgSubscription {\n status?:\n | 'active'\n | 'trialing'\n | 'past_due'\n | 'canceled'\n | 'incomplete'\n | 'unpaid'\n /**\n * WHY the subscription ended, mirrored from Stripe's\n * `cancellation_details.reason` on `customer.subscription.deleted`\n * (AGL-1877). `'payment_failed'` is Stripe having exhausted the dunning\n * retries; `'cancellation_requested'` is the customer leaving on purpose.\n *\n * The distinction is not cosmetic — it is the only thing that tells a\n * DELINQUENT workspace apart from one that simply left, and both arrive as\n * the same event with the same `status: 'canceled'` and the same\n * `plan: 'free'` mirror. Without it `shouldAutoLockOrgForBilling` had no\n * reachable delinquent state at all (see `billing-auto-lock.ts`), the\n * churn funnel counted a dunning failure as voluntary churn, and nobody\n * could be told which had happened.\n *\n * Absent on every org whose subscription ended before this shipped, and\n * every consumer must fail CLOSED on that — an unknown reason is not a\n * proven payment failure.\n */\n canceledReason?: string | null\n priceId?: string\n /**\n * Billing interval of the plan item (AGL-532), webhook-mirrored: the\n * Billing page initializes its monthly/annual toggle from it and plan\n * switches keep it unless the toggle says otherwise.\n */\n interval?: 'month' | 'year'\n currentPeriodEnd?: ITimestamp\n /**\n * A downgrade scheduled for the current period end (AGL-1862). Set by\n * `/api/billing/subscription` when a switch walks DOWN the self-serve\n * ladder — the Stripe subscription schedule owns the transition, this is\n * the manager-gated mirror the billing page renders. `null` (not absent)\n * once released, so a merge clears it. Rides inside `subscription` because\n * `pickOrgBillingFields` drops any other top-level key.\n */\n pendingDowngrade?: {\n plan: string\n interval: 'month' | 'year'\n /** ISO timestamp of the period end the schedule flips at. */\n effectiveAt: string | null\n scheduleId: string\n } | null\n /**\n * Negotiated custom price as a **monthly-normalized** USD figure (AGL-1110),\n * set when an enterprise org bills at an ad-hoc amount rather than a plan's\n * list price — e.g. `agency` capability at $2,730/mo. An annual custom deal\n * is stored ÷12 here (the yearly total lives on the Stripe price), so every\n * reader treats it as monthly and never re-divides. Webhook-mirrored from the\n * Stripe subscription's recurring price. When present it is the truth for\n * revenue — `orgListPriceMonthlyUsd`/`orgMonthlyRevenueUsd` use it instead of\n * the plan default (fixes the custom-price MRR under-report, AGL-1110).\n */\n customMonthlyUsd?: number\n}\n\n/**\n * The org's billing/entitlement doc shape — the view of `orgs/{orgId}`\n * that `useCurrentOrg()`, the plugin-page `org` prop, and the\n * entitlement resolvers carry. (Formerly `AglynTenant`; the alias was\n * removed in AGL-444.)\n */\n/**\n * `orgs/{orgId}.bandwidthCap` — the engaged free-plan bandwidth cap\n * (AGL-1967/2070/2155).\n *\n * STAMPED WITH THE MONTH IT IS FOR, and never cleared. A new month simply\n * stops matching, so the cap lifts on its own with no write: a marker whose\n * removal depends on a cron running is a marker that stays engaged when the\n * cron fails, which would take a customer's site down over an infrastructure\n * problem rather than over their traffic.\n *\n * Plain numbers rather than `ITimestamp`, matching `suspendedUntilMs`, so the\n * value survives the tenant's cache serialization unchanged.\n */\nexport interface OrgBandwidthCap {\n /** UTC `YYYY-MM` this cap was engaged for. Only the current month refuses. */\n month: string\n /** When the sweep engaged it. Diagnostic only; nothing gates on it. */\n engagedAt?: number\n /** The org-wide page views measured at engage time. Diagnostic. */\n pageViews?: number\n /** The band those page views were measured against. Diagnostic. */\n includedPageViews?: number\n}\n\n/**\n * The organization document.\n *\n * NOT composed from a plugin-augmented interface, deliberately (AGL-3124).\n * `org-write-deny-coverage.spec.ts` enumerates this interface's fields by\n * reading THIS FILE'S SOURCE, and checks every one of them against the\n * Firestore write-deny rules; a field a plugin declared from its own file\n * would be invisible to that sweep, so composing here would open a rules\n * coverage hole to save a plugin an already-solved problem. A plugin's own\n * settings block goes through `registerPluginConfigSchema` into\n * `pluginSettings/{pluginId}`, which is its own document under its own rule.\n */\nexport interface AglynOrgBilling extends AglynDocument {\n /** The document id, injected by the reader — never a stored field. */\n $id: OrgUid\n /**\n * The org's display name, written by `createOrganization` and renamed\n * through /api/orgs/settings (the name is denormalized onto every\n * membership row, so the rename fans out server-side). Client-writable by\n * an org admin on purpose — see `ORG_CLIENT_WRITABLE_FIELDS`.\n *\n * Spelled `displayName` here until AGL-1355; nothing ever read that, and\n * the document has always carried `name`. The coverage guard derives the\n * org's field set from this interface, so a field that does not exist\n * bought nothing but a blind spot.\n */\n name?: string\n /** Free-text workspace description; nothing gates on it. */\n description?: string\n /**\n * The IANA zone this workspace's published dates read in (AGL-3237) —\n * `America/Chicago`, `Europe/Berlin`. Absent is UTC.\n *\n * What it decides is which CALENDAR DAY an instant is attributed to on a\n * published site, which is an editorial fact about the publisher: a post\n * that went out at 19:30 in Chicago is dated the 21st here and was dated\n * the 22nd while UTC decided it. Sites inherit it; see\n * `resolveSiteTimeZone`.\n *\n * SERVER-OWNED: written through /api/orgs/settings and denied to the\n * client SDK, because `resolveSiteTimeZone` reads it in `app-utils` and\n * the write-deny guard treats a resolver input as server-owned.\n * Nothing bills, gates or routes on it, and it is validated at every read\n * through `isSupportedTimeZone`, so an unusable value renders as UTC rather\n * than throwing inside a page render.\n */\n timeZone?: string\n /**\n * The sites this organization declared ONE SENDER, `{ [groupId]: { name,\n * hostIds } }` (`consent-groups.ts`). Absent, every site is alone.\n *\n * SERVER-OWNED: denied to the client SDK, because `consentGroupForHost`\n * reads it in `app-utils` and decides who a marketing basis covers and whose\n * opt-outs hold a send. The Emails hub's consent group editor changes it\n * through `/api/orgs/consent-groups`, and the one write is the executor's\n * declare step, taken only after every separating site's refusals have\n * been carried both ways (`consent-group-change.ts`).\n */\n consentGroups?: Record<string, { name: string; hostIds: string[] }>\n /**\n * A consent group change in flight (AGL-3320): `{ changeId, phase,\n * hostIds, startedAtMs, declaredAtMs? }`, present exactly while one runs\n * and cleared when it finishes or is canceled. The job it names is\n * `orgs/{orgId}/consentGroupChanges/{changeId}`.\n *\n * SERVER-OWNED: written only by the executor, which relies on it as the\n * lock — a second change, and a deletion of a site it names, are refused\n * while it stands — so a client able to clear it could run two changes at\n * once, and one able to set it could block every change after it.\n */\n consentGroupsChange?: {\n changeId: string\n phase: 'carry' | 'rehome' | 'sweep'\n hostIds: string[]\n startedAtMs: number\n declaredAtMs?: number\n }\n /**\n * Whether a declared group's sites wait for each other's confirmation click\n * (AGL-3316). Absent or `false` is off: a pending confirmation holds only\n * the asking site's mail.\n *\n * SERVER-OWNED: written through /api/orgs/settings and denied to the client\n * SDK, for the reason `consentGroups` is — `consentGroupForHost` resolves it\n * into every group a send path reads.\n */\n consentGroupsAwaitConfirmation?: boolean\n /** The workspace URL segment, reserved through `orgSlugs/{slug}`. */\n slug?: string\n /**\n * The owner's uid. Spelled `ownerId` here until AGL-1355 — every reader\n * has always used `ownerUid` (the key `createOrganization` writes and the\n * rules deny), so the declared name was dead.\n */\n ownerUid?: UserUid\n hosts?: Record<HostUid, true>\n /** Subscription tier; missing/unknown plans resolve as `free`. */\n plan?: OrgPlan\n /** Per-org entitlement overrides (admin console); win over plan defaults. */\n entitlements?: OrgEntitlements\n /**\n * Per-org RELEASE-flag overrides (AGL-1635) — a different axis from\n * `entitlements`, which asks what the org's plan includes. These ask\n * whether an unreleased feature is switched on for this one customer, and\n * win over both the Remote Config value and the rollout bucket.\n *\n * Staff-only, and deliberately narrower than `entitlements`: super staff\n * alone may write it, matching the platform-wide flag editor\n * (`/api/admin/flags` is super-only), because forcing a flag on for an org\n * is the same class of act as flipping it for everyone — just scoped.\n * Read through `parseOrgReleaseFlagOverrides`, never directly.\n *\n * Typed loosely on purpose. The keys ARE `ReleaseFlagKey`, but that union\n * lives in `app-utils/release-flags` and foundation cannot import\n * app-utils (see `platform.types.ts`). Declaring the narrow type here\n * would also overstate what is on disk: this map outlives registry\n * renames, so a retired key is a thing that genuinely exists in Firestore.\n * `parseOrgReleaseFlagOverrides` is what narrows it, dropping unknown keys\n * and non-booleans.\n */\n releaseFlags?: Record<string, boolean>\n /**\n * White-label brand identity (White-Label Phase 1). Applied only when the\n * org carries the `whiteLabel` entitlement; read exclusively through\n * `resolveBrandingProfile`, never directly, so no surface diverges.\n */\n brandingProfile?: OrgBrandingProfile\n /**\n * Enterprise SSO config (AGL-1101). Applied only when the org carries the\n * `ssoEnabled` entitlement; the console routes sign-in through the org's\n * GCIP tenant/provider named here. See `OrgSsoConfig`.\n */\n sso?: OrgSsoConfig\n /** Per-org plugin switchboard (AGL-416); see plugin-manager/enabled-plugins. */\n enabledPlugins?: string[]\n /** Purchased addon seats (AGL-112); billed monthly per seat. */\n seatAddons?: OrgSeatAddons\n /**\n * POS register seats assigned out of the org pool (AGL-1775). An\n * ENTITLEMENT INPUT: it raises a per-site cap, so it is Admin-SDK-only.\n * @see OrgRegisterAllocations\n */\n registerAllocations?: OrgRegisterAllocations\n /**\n * Per-site collaborator seats assigned out of the org pool (AGL-2439). An\n * ENTITLEMENT INPUT: it raises a per-site cap, so it is Admin-SDK-only.\n * @see OrgCollaboratorAllocations\n */\n collaboratorAllocations?: OrgCollaboratorAllocations\n /**\n * The free plan's engaged BANDWIDTH CAP (AGL-1967/2070/2155), denormalized\n * onto this doc by the `usage-alerts` cron so the serving path can refuse a\n * capped site without a read of its own.\n *\n * An ENTITLEMENT INPUT in the strongest sense: it is the only thing standing\n * between a free site that has blown its band and unmetered egress, so a\n * client-writable value would let an org admin lift their own cap by\n * deleting one field. Admin-SDK only; denied in the rules.\n *\n * Read exclusively through `bandwidthCapEngaged` (`app-utils/bandwidth-cap`),\n * never directly — the marker on its own is not the answer. The resolver\n * re-derives the plan on every read, which is what lets an org that upgrades\n * mid-month start serving again without waiting for a cron to clear it.\n */\n bandwidthCap?: OrgBandwidthCap\n /**\n * The org's assist hard-cap switch (AGL-2653) — see `OrgAssistOverage`.\n * Admin-SDK-only, denied in the rules beside `storageOverage`; read\n * through `resolveAssistHardCap`.\n */\n assistOverage?: OrgAssistOverage\n stripeCustomerId?: string\n subscription?: OrgSubscription\n /**\n * Discount on the org's own Aglyn subscription (AGL-1105) — staff-applied\n * enterprise deal or a redeemed coupon. `orgMonthlyRevenueUsd` subtracts it\n * for net-of-discount MRR; `orgListPriceMonthlyUsd` ignores it (list price).\n */\n discount?: OrgDiscount\n /**\n * Explicit **comped enterprise** marker (AGL-1110). Makes the org read as\n * \"Enterprise\" (via `isEnterpriseOrg`) without a negotiated custom price —\n * for internal/dogfood accounts (e.g. Aglyn's own org) that carry full\n * Enterprise capability + SSO but are 100%-discounted, so they collect $0\n * while infra cost is still metered. Staff-set; distinct from a paying\n * custom-priced enterprise, which qualifies via `subscription.customMonthlyUsd`.\n */\n enterprise?: boolean\n /**\n * The bare `subscription.status` word the Stripe webhook mirrors back onto\n * this doc for the AGL-275 dunning banner and `resolveEffectivePlan`\n * (AGL-1028). An ENTITLEMENT INPUT: a dead subscription downgrades a paid\n * plan to free, so a client-writable value would restore the plan.\n */\n billingStatus?: string\n /** Staff suspension (AGL-202): set = all the org's sites serve 503. */\n suspendedAt?: ITimestamp | null\n suspendedReason?: string\n /**\n * Whether the suspension above is IN FORCE, stored so the staff\n * Organizations list can filter by it with an equality (AGL-3416). `false`\n * on every unsuspended org; written beside the `suspended*` family by the\n * lockdown core and cleared on a lapsed timed lock by\n * `settleLapsedSuspensions`. Never an enforcement input: every enforcing\n * reader asks the family itself.\n */\n suspended?: boolean\n /**\n * GDPR erasure request (AGL-206): hard deletion happens ONLY via `eraseOrg`\n * after a 7-day hold from this stamp — reached by the\n * `/api/admin/run-erasures` cron, or by hand with\n * tools/scripts/erase-tenant.mjs, which calls the same function (AGL-1481).\n */\n erasureRequestedAt?: ITimestamp | null\n /**\n * The CRM's organization-wide settings (AGL-2613), written from\n * CRM → Settings by an owner or admin with the client SDK — see\n * `ORG_CLIENT_WRITABLE_FIELDS`. One map rather than a key per setting, so\n * the section can grow without a rules change each time.\n */\n crm?: OrgCrmSettings\n /**\n * The plan the platform team has asked this workspace to move to\n * (AGL-3466) — see `OrgUpgradeProposal`.\n *\n * SERVER-OWNED: written only by /api/admin/org-upgrade-proposal (staff,\n * audited) and cleared by `writeOrgBilling` once a subscription is live,\n * both Admin SDK. Denied to every client in the rules, staff included,\n * because `standingUpgradeProposal` reads it in `app-utils` and the\n * proposal is the platform's statement to the customer, not theirs.\n */\n upgradeProposal?: OrgUpgradeProposal\n createdAt?: ITimestamp\n updatedAt?: ITimestamp\n}\n\n/**\n * The platform team's request that a workspace move to a paid plan\n * (AGL-3466).\n *\n * The separate step that follows an evaluation: a client handed a workspace\n * looks around first, on whatever the workspace already has, and is asked to\n * upgrade only once staff record this. While it stands and no subscription\n * is live, the workspace's billing managers see it on the org home and on\n * Billing, with the plan preselected. Nothing about it grants or bills.\n */\nexport interface OrgUpgradeProposal {\n /** A paid plan the workspace can buy itself — never `free` or `enterprise`. */\n plan: OrgPlan\n /** The staff member who proposed it. */\n proposedBy: string\n /** Epoch millis, so every cache serialization reads it back unchanged. */\n proposedAt: number\n /** An optional line from staff, shown with the proposal. */\n note?: string\n}\n\n/**\n * What the CRM lets an organization decide for every site at once\n * (AGL-2613). Every key is optional and off when absent: a setting that\n * has never been touched behaves as the product did before it existed.\n */\nexport interface OrgCrmSettings {\n /**\n * Create a company from a captured contact's work email domain when no\n * company the capturing site can see carries that domain. Off by default:\n * a company minted from every domain that ever submitted a form is a list\n * nobody asked for. Public mailbox domains never qualify either way.\n */\n autoCreateCompanies?: boolean\n /**\n * Per-site settings, keyed by host id (AGL-2618). On the ORG document\n * rather than on each host document because the reader is the org-level\n * assignment pass — one read of one document answers every site's default\n * — and because the writer is the same owner-or-admin the rest of this\n * map admits, through the same client branch. A host document would need\n * its own rules clause for a key only the CRM reads.\n */\n hosts?: Record<string, OrgCrmHostSettings>\n /**\n * The assignment rules, in the order they are tried (AGL-2618). The first\n * rule whose every condition holds names the owner; the site's default\n * owner is the fallback when none does. Bounded by\n * `CRM_ASSIGNMENT_RULES_MAX` in the section that writes it.\n */\n assignmentRules?: OrgCrmAssignmentRule[]\n /** The round-robin pool and its pointer — see `OrgCrmRoundRobin`. */\n roundRobin?: OrgCrmRoundRobin\n}\n\n/** What one site decides for records captured on it (AGL-2618). */\nexport interface OrgCrmHostSettings {\n /**\n * The member every record captured on this site is handed to when no\n * assignment rule claimed it. Absent: the record stays unassigned, which\n * is what the product did before the setting existed.\n */\n defaultOwnerUid?: string\n}\n\n/**\n * One assignment rule (AGL-2618): `when` every named condition holds for a\n * capture, `assign` the record this way. A condition left out is not a\n * condition — a rule naming only a source matches every capture from that\n * source — and a rule naming nothing matches every capture, which is how a\n * catch-all is written. `source` is a `ContactSource`, typed as a string\n * here because the foundation cannot import the capture vocabulary; the CRM\n * module narrows it.\n */\nexport interface OrgCrmAssignmentRule {\n id: string\n when: {\n source?: string\n formId?: string\n emailDomain?: string\n tag?: string\n }\n assign: { memberUid: string } | { roundRobin: true }\n}\n\n/**\n * The round-robin pool (AGL-2618): the members handed records in turn, and\n * WHO GOT THE LAST ONE. The pointer is a uid rather than an index so that\n * editing the pool — a member added, removed or moved — never skips or\n * repeats anybody: the next record goes to whoever follows the last\n * recipient in the pool as it stands, and to the first member when the last\n * recipient is no longer in it. Advanced only by the server, inside the\n * transaction that writes the owner, so two captures landing together take\n * two different members.\n */\nexport interface OrgCrmRoundRobin {\n memberUids?: string[]\n lastAssignedUid?: string\n}\n\n/**\n * WHO OWNS EACH FIELD OF `orgs/{orgId}` (AGL-1355).\n *\n * AGL-1354 found four server-owned keys — `brandingProfile`, `sso`,\n * `discount`, `enterprise` — that the Firestore rules' org-update key diff\n * had drifted past, so an org admin holding nothing but the Firebase client\n * SDK could write them on any plan. The keys are closed. The MECHANISM that\n * opened them was a hand-maintained deny-list with nothing checking it, and\n * that is what these two maps close.\n *\n * The rule is DEFAULT-DENY, and it is enforced from the interface above:\n * `org-write-deny-coverage.spec.ts` reads every field declared on\n * `AglynOrgBilling`, and one that appears in neither the rules' deny-list nor\n * one of these maps FAILS THE BUILD, naming the field. Adding a field to the\n * interface therefore forces the ownership decision here, at the declaration,\n * on the same commit — instead of shipping client-writable and staying that\n * way until the next audit.\n *\n * So: add a field above, and either add it to the `hasAny([...])` list in\n * `cloud/firebase-firestore.rules` under `match /orgs/{orgId}` (server-owned:\n * anything an entitlement, a price, a routing decision or a staff judgement\n * reads) or add it below WITH A REASON. When in doubt, deny it — a field the\n * server writes through an Admin-SDK route loses nothing by being denied to\n * the client, and that is true of every key AGL-1354 closed.\n *\n * The entries are the fields an org admin may set from the client SDK. The\n * reason is mandatory and is the whole value of the map: it records that\n * someone decided, rather than that someone forgot.\n */\nexport const ORG_CLIENT_WRITABLE_FIELDS: Readonly<Record<string, string>> = {\n name:\n 'The workspace name. Deliberately writable by an org admin — the rules ' +\n 'admit the rename branch and `firestore-rules.test.mjs` asserts it still ' +\n 'succeeds. Cosmetic: no entitlement, price or routing decision reads it. ' +\n '(The console renames through /api/orgs/settings anyway, because the name ' +\n 'is denormalized onto every membership row and the fan-out is a server ' +\n 'job — but the client write is allowed and must stay allowed.)',\n description:\n 'Free-text workspace blurb. Nothing resolves, gates or bills on it, and ' +\n 'no server route owns it.',\n createdAt:\n 'Creation stamp seeded by `createOrganization`. Nothing gates on it; the ' +\n 'staff org list only displays it. Denying it would buy nothing and would ' +\n 'break the merge-writes below, which stamp the pair together.',\n updatedAt:\n 'Last-write stamp. Every client write that IS allowed sets it in the same ' +\n '`setDoc(..., { merge: true })` — the staff suspension, erasure-request ' +\n 'and plan-override cards all do — so denying it would deny those writes.',\n crm:\n 'The CRM settings map (AGL-2613): `autoCreateCompanies`, the per-site ' +\n 'default owner, the assignment rules and the round-robin pool ' +\n '(AGL-2618), and whatever the CRM → Settings section adds beside them. ' +\n 'Deliberately writable by an org owner or admin — the section writes it ' +\n 'client-direct by dotted path. No entitlement, price, routing decision ' +\n 'or staff judgement reads it; its readers are the contact capture door ' +\n 'deciding whether to mint a company record from an email domain and ' +\n 'whom to hand a new record to, which are choices about the org\\'s own ' +\n \"data that the org's managers are the right people to make. The pool's \" +\n 'pointer (`crm.roundRobin.lastAssignedUid`) is advanced by the server ' +\n 'inside the assigning transaction; a client that moved it would only ' +\n 'change who is next, never what anybody may see.',\n}\n\n/**\n * Fields declared on `AglynOrgBilling` that are NOT stored on the document,\n * and so cannot be written by anyone. Separate from the map above because\n * \"the client may set this\" and \"this is not a field\" are different\n * statements, and collapsing them would let a genuinely client-writable field\n * hide behind a synthetic one.\n */\nexport const ORG_UNPERSISTED_FIELDS: Readonly<Record<string, string>> = {\n $id: 'The document id, injected by the reader. Never written as a field.',\n}\n\n"],"names":["ORG_CLIENT_WRITABLE_FIELDS","name","description","createdAt","updatedAt","crm","ORG_UNPERSISTED_FIELDS","$id"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;CAgBC,GA+zCD;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,MAAMA,6BAA+D;IAC1EC,MACE,2EACA,6EACA,6EACA,8EACA,2EACA;IACFC,aACE,4EACA;IACFC,WACE,6EACA,6EACA;IACFC,WACE,8EACA,4EACA;IACFC,KACE,0EACA,kEACA,2EACA,4EACA,2EACA,2EACA,wEACA,0EACA,2EACA,0EACA,yEACA;AACJ,EAAC;AAED;;;;;;CAMC,GACD,OAAO,MAAMC,yBAA2D;IACtEC,KAAK;AACP,EAAC"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/aglyn/src/lib/foundation/definitions/org-billing.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 * The org's billing/entitlement vocabulary (AGL-443 naming cleanup).\n *\n * HISTORY: these types were spelled `Tenant*` until AGL-444 — they\n * predate the organizations migration (AGL-232..238); the retired\n * `tenants/{uid}` collection's billing shape was mirrored ONTO the org\n * doc and the names came along. The alias is gone: everything here is\n * `Org*` and describes fields of `orgs/{orgId}`. The last persisted\n * tenant spellings (the `users.{uid}.tenants` map, Stripe\n * `metadata[tenantId]`, `host.tenantId`) were retired pre-launch in\n * AGL-445 — billing keys off `metadata[orgId]` now.\n *\n * Convention (see the docs-site glossary): \"organization/org\" is the\n * entity; \"workspace\" is the user-facing word for it; \"tenant\" is\n * reserved for the published-site runtime (`apps/tenant`,\n * `@aglyn/tenant-*` libs).\n */\n\nimport type { ITimestamp } from '@aglyn/shared-util-timestamp'\nimport type {\n AglynDocument,\n HostUid,\n OrgUid,\n UserUid,\n} from './platform.types'\n/*\n * The plugin half of the two composed shapes below (AGL-3124).\n *\n * Each is an EMPTY interface a plugin augments with `declare module`, so the\n * keys a plugin owns are part of `OrgEntitlements` and `OrgFeatureFlags`\n * without the core listing them. The AI credits, the CRM's one-to-one email\n * pace, the form-submission and workflow-run bands, the marketplace's take\n * rate and selling gate and commerce's three gated features are declared that\n * way, each in its plugin's `plan-entitlements` module (AGL-3080).\n *\n * `import type` and nothing else: TypeScript erases it, so the plugin-manager\n * module is not on this module's runtime graph and a published page that\n * reaches the billing types (through the foundation barrel it must not reach\n * anyway) carries nothing new.\n */\nimport type {\n PluginEntitlementFeatures,\n PluginEntitlementQuotas,\n} from '../../plugin-manager/plugin-entitlement-keys'\n\nexport type { OrgUid } from './platform.types'\n\n\n/** Hosted in master catalog */\n/**\n * SaaS subscription tiers (Tenant Billing & SaaS Plans, AGL-38..41).\n * Pricing v3 (2026-07) inserted `scale` between business and advanced to\n * fill the $139→$399 gap, and added `agency` above advanced for\n * high-volume multi-site orgs — see the Pricing Decision Log.\n *\n * `enterprise` (AGL-1118) is a REAL plan, not a display label: it tops the\n * ladder with unlimited capacity plus white-label and SSO, and it is the ONE\n * tier with no list price — it is staff-provisioned per deal (AGL-1110), never\n * self-serve. Surfaces that offer plans for sale iterate `SELF_SERVE_PLANS`,\n * which excludes it; surfaces that merely NAME the org's plan read\n * `PLAN_LABELS` and get \"Enterprise\" for free.\n */\nexport type OrgPlan =\n | 'free'\n | 'starter'\n | 'pro'\n | 'business'\n | 'scale'\n | 'advanced'\n | 'agency'\n | 'enterprise'\n\n/**\n * The CORE boolean feature gates per plan; quotas live beside them as\n * numbers. Read {@link OrgFeatureFlags} instead — this half exists so the\n * plan tables can stay exhaustive over the platform's own gates while a\n * plugin declares its own (AGL-3124).\n */\nexport interface CoreOrgFeatureFlags {\n /** A/B experiments (AGL-252); Business tier. */\n abTesting?: boolean\n versioning?: boolean\n /**\n * Reusable components WITHOUT a count: Starter and above. Since AGL-3615 a\n * component's create is counted against `componentsPerHost` alone — Free\n * saves one — so this flag no longer decides whether a site may make one.\n * It states that the allowance is unlimited, and a per-org override that\n * turns it on lifts `componentsPerHost` with it, which is how an org\n * granted the feature by contract before the count existed keeps it.\n */\n reusableComponents?: boolean\n customDomain?: boolean\n /**\n * Send mail as a domain the CUSTOMER owns — `hello@acme.com` — by publishing\n * our SPF, DKIM and return-path records in their own zone.\n *\n * Distinct from `customDomain`, which is the site's public web address and\n * authorizes nothing about mail; and distinct from `whiteLabel`, which\n * replaces the Aglyn brand — product name, logo, colors, support URL,\n * console chrome, favicon — across every branded surface. Sending as your\n * own name is one narrow consequence of white-labeling rather than the\n * whole of it, so the two cannot share a flag without the wider capability\n * following the narrower one wherever it goes.\n *\n * It is also the CHEAP half of the sending model, which is why it sits low\n * on the ladder. A customer-owned domain costs the platform one provider\n * domain object and NOTHING in our own DNS zone, because the customer\n * publishes the records; the platform subdomain a site is issued costs a\n * provider slot, three records in our zone and a permanent place in the\n * re-verification sweep. Holding the free-to-us option behind the highest\n * paywall makes the expensive one the default at every tier that can send.\n *\n * Read through `checkEntitlement` at the two routes that write a sending\n * identity: the org's domain list, and the per-site selection.\n */\n customSendingDomain?: boolean\n /**\n * Hold a sending subdomain of the PLATFORM's own —\n * `hello@{label}.mail.aglyn.app` — provisioned for one site inside Aglyn's\n * mail apex.\n *\n * The EXPENSIVE half of the sending model, and the reason it is a separate\n * flag from `customSendingDomain`. A domain the customer owns costs one\n * provider domain object and nothing in our zone, because they publish the\n * records. One of these costs a provider slot, THREE records in our own\n * zone, and a permanent place in the re-verification sweep — per site,\n * forever. The two capabilities are decided against different resources, so\n * a shared flag would move a bounded one whenever the unbounded one is\n * re-cut.\n *\n * The flag is the SECOND of two conditions and never the only one. Nothing\n * provisions on entitlement alone: a merchant asks from the sending-identity\n * route, this says whether the org carries what they asked for, and the\n * provider ceiling still refuses beyond the account's allowance. A site\n * refused at any of the three sends on the shared pool rather than not at\n * all.\n *\n * Read through `checkEntitlement` at\n * `libs/aglyn/src/lib/app-utils/dedicated-sending-domain.ts`, which is the\n * only reader — the claim path in `host-sending-domain.ts` goes through it.\n */\n dedicatedSendingDomain?: boolean\n removeBranding?: boolean\n /** Schedule a version to publish at a date/time (tier above versioning). */\n scheduledPublishing?: boolean\n /** AI copy assist in the besigner (AGL-89). */\n aiAssist?: boolean\n /**\n * Generative building and automation (AGL-2896): pages, components,\n * layouts, templates, SEO, email, campaigns, A/B variants, analytics\n * insights, products, CRM records, onboarding and workflows produced by\n * the assistant rather than assembled by hand.\n *\n * Distinct from `aiAssist`, which is the chat guide, copy assist and\n * generate-section rung every tier from Pro up carries. This is the\n * expensive rung: a generated surface carries the node tree, the catalog\n * and the theme in and iterates, so it draws credits by the hundreds where\n * a question draws tens. No self-serve tier includes it; the Aglyn AI\n * add-on (`OrgSeatAddons.aiAddon`) switches it on and adds the credit band\n * that funds it, and `resolveOrgEntitlements` flips this flag together\n * with `aiAssist` when the add-on is present. Enterprise carries it in the\n * agreement.\n */\n aiGenerative?: boolean\n /** No-code workflow builder (AGL-101). */\n workflows?: boolean\n /** Datasets + repeatable components (AGL-102/103). */\n dataStore?: boolean\n /** Video/file uploads in the media manager (AGL-162). */\n videoMedia?: boolean\n /** Appointment bookings (AGL-159). */\n bookings?: boolean\n /**\n * THE CRM (AGL-2611): contacts, leads, companies, the deals pipeline,\n * tasks, reports and custom fields, the CRM automation steps, and the\n * `crm:*` REST resources.\n *\n * The Contacts section included (AGL-2851). Capture still writes a site's\n * audience into the contacts collection on every plan including Free,\n * banded by `contactsPerHost`, and exporting or erasing those people stays\n * on every plan in Settings → Privacy. What this flag gates is the CRM a\n * team works them in, and it is the upgrade motive from Free to the first\n * paid tier rather than a line on top of one: for the small-business buyer\n * the CRM is the reason to pick a platform over a page builder.\n *\n * Read by the console shell through the CRM extension's and its widgets'\n * `featureFlag` (so the shell refuses every CRM section and page before\n * one mounts), by the `crm/*` routes, by the automation executor before a\n * CRM step runs, by the REST dispatcher in front of the CRM resources,\n * contacts included, and, restated, by the Firestore rules on a client's\n * CRM writes. A per-org override on `entitlements.features.crm` works the\n * way every other flag's does.\n */\n crm?: boolean\n /**\n * SEQUENCES (AGL-2974): one-to-one, multi-step email sequences a rep sends\n * from their own connected mailbox, logged on the CRM's records. The key\n * below keeps the name the feature shipped under, because it is stored on\n * the org (AGL-3199).\n *\n * False on EVERY plan, Enterprise included. Which tiers carry sequences\n * and connected mailboxes, and at what caps, is a packaging decision that\n * has not been made, so no plan may claim it. An organization reaches it\n * only through the per-org override on `entitlements.features.outreach`.\n *\n * Read by the console shell through the Sequences extension's\n * `featureFlag`, by the plugin's routes, and, restated, by the Firestore\n * rules in front of the `outreach*` collections. The rules carry no plan\n * list for it because no plan grants it; when packaging lands, the plan\n * table and those rules change together.\n */\n outreach?: boolean\n /**\n * Basic presentational interactions (AGL-577): menu/drawer open-close,\n * element show/hide, class toggles, sticky nav, navigation, site\n * alerts. Included on ALL plans — pure client-side DOM with no server\n * cost. The `actions` flag below gates the powerful automation steps\n * (server dispatch, runJs, analytics, overlays, raw HTML).\n *\n * `true` on all eight tiers, and NO code gates on it — which makes it look\n * dead, and it has now been filed as dead once (AGL-2082). It is not.\n * `tools/marketing/build-pricing-tables.mts` reads its value to emit the\n * \"Interactions\" row on the public /pricing compare table, ticked on every\n * plan, and the Free plan card bullets it. That row is the claim that a\n * hover-to-open menu is not a paid feature — a real competitive statement,\n * and the reason `true` everywhere is a DECISION rather than a default.\n *\n * So: do not delete it as dead weight. Deleting it silently removes a\n * public pricing row while the Figma frames still carry it in all four\n * responsive variants, which is a pricing call and not a cleanup.\n */\n interactions?: boolean\n /** Event → action automation builder (AGL-148). */\n actions?: boolean\n /** Outbound/inbound webhooks (AGL-149). */\n webhooks?: boolean\n /** Customer REST API v1 + API keys (AGL-615); Business tier. */\n apiAccess?: boolean\n /** Whole-site export/backup + restore (AGL-163). */\n siteExport?: boolean\n /** Multilingual sites (AGL-164): locale variants + switcher. */\n multilingual?: boolean\n /** Event Calendar add-on (AGL-145); paid, not part of any base tier. */\n eventCalendar?: boolean\n /** URL redirects manager (AGL-154). */\n redirects?: boolean\n /** Per-screen traffic analytics (AGL-150). */\n screenAnalytics?: boolean\n /** CDN delivery + responsive image variants for media (AGL-175). */\n mediaCdn?: boolean\n /** Announcement bar + promotional popups (AGL-195/196). */\n marketingOverlays?: boolean\n /** Full storefront commerce: catalog, cart, checkout (AGL-278). */\n commerce?: boolean\n /** Console point-of-sale mode (AGL-312). */\n pos?: boolean\n /** Entitlement-gated screens/sections/video paywalls (AGL-309). */\n contentGating?: boolean\n /** Verified-buyer product reviews (AGL-324). */\n productReviews?: boolean\n /** Abandoned checkout recovery emails (AGL-323). */\n abandonedCart?: boolean\n /** Dropship supplier routing on paid orders (AGL-289). */\n dropshipRouting?: boolean\n /**\n * White-label the platform (White-Label Phase 1): replace the Aglyn brand\n * — product name, logo, colors, support URL, transactional email from-name\n * — with the org's own `brandingProfile` across every branded surface.\n * Agency ($799) and Enterprise carry it on the plan (AGL-1118); any other\n * tier needs a per-org `entitlements` override. Strictly broader than\n * `removeBranding`, which only drops the\n * \"Made with Aglyn\" badge on published sites; white-label REPLACES the\n * brand rather than merely hiding it. Every branded surface resolves the\n * effective brand through `resolveBrandingProfile` so it can never drift.\n */\n whiteLabel?: boolean\n /**\n * Enterprise SSO (AGL-1101): the org's console users sign in through the\n * org's own SAML/OIDC IdP, wired as a per-org GCIP tenant (`org.sso`).\n * Distinct from `whiteLabel` — an Agency org can have one without the other.\n * Carried by the `enterprise` plan (AGL-1118) and false on every other base\n * plan; a lower tier needs a per-org `entitlements` override, which is how\n * enterprise orgs provisioned before that plan existed still get it. Gates\n * the staff SSO-config card and the SSO sign-in path; a non-entitled org can\n * neither configure nor use SSO.\n */\n ssoEnabled?: boolean\n}\n\n/**\n * Boolean feature gates per plan, the platform's and its plugins' together.\n *\n * {@link CoreOrgFeatureFlags} is the platform's own set — what\n * `PLAN_ENTITLEMENTS` declares for every plan, and what\n * `Required<CoreOrgFeatureFlags>` keeps exhaustive.\n * {@link PluginEntitlementFeatures} is the plugin half (AGL-3124): a plugin\n * declares a gate of its own into it with `declare module`, and its answer\n * per plan comes from that plugin's `plan-entitlements` declaration or its\n * `registerPluginEntitlements` defaults rather than from a row core writes —\n * which is why a plugin's gate joins HERE and not above. Every reader keeps reading `OrgFeatureFlags`, and\n * `keyof OrgFeatureFlags` admits a plugin's key the moment it is declared.\n */\nexport interface OrgFeatureFlags\n extends CoreOrgFeatureFlags,\n PluginEntitlementFeatures {}\n\n/**\n * An org's white-label brand identity (White-Label Phase 1). Populated on\n * the org doc (`orgs/{orgId}.brandingProfile`) and applied ONLY when the org\n * carries the `whiteLabel` entitlement (Agency or Enterprise plan, or a\n * per-org override);\n * otherwise every surface falls back to the Aglyn defaults baked into\n * `resolveBrandingProfile`. Every field is optional — a partial profile\n * still resolves, with the Aglyn default filling each gap — so an agency can\n * set just a product name and from-name without supplying logos.\n */\nexport interface OrgBrandingProfile {\n /** Brand name shown in place of \"Aglyn\" (console chrome, emails, badges). */\n productName?: string\n /** Primary/full-color logo URL (light backgrounds, console chrome). */\n logoUrl?: string\n /**\n * The logo for DARK grounds (AGL-3406): the console chrome in dark mode and the\n * published site's attribution badge, which always sits on a dark pill.\n * Unset falls back to {@link logoUrl}.\n */\n logoDarkUrl?: string\n /** Favicon URL for branded surfaces. */\n faviconUrl?: string\n /**\n * The favicon for a browser whose tab strip is dark, emitted as a second\n * `<link rel=\"icon\">` under `(prefers-color-scheme: dark)`. Unset falls back\n * to {@link faviconUrl}.\n */\n faviconDarkUrl?: string\n /** Brand primary color as a CSS color (hex), e.g. `#1a73e8`. */\n primaryColor?: string\n /** Support/help destination linked from branded surfaces and emails. */\n supportUrl?: string\n /** Transactional email from-name (the display name before the address). */\n fromName?: string\n /** Logo URL specifically for the email header (often a hosted PNG). */\n emailLogoUrl?: string\n /** Custom console domain the agency serves the app on (Phase 4 wiring). */\n customConsoleDomain?: string\n}\n\n/**\n * An org's enterprise SSO configuration (AGL-1101, Phase 1). Applied ONLY when\n * the org carries the `ssoEnabled` entitlement. The actual IdP lives in a\n * per-org **GCIP tenant** (`tenantId`) with a SAML/OIDC **provider**\n * (`providerId`); this block is the org-doc mirror the console reads to route\n * sign-in and the staff card edits. A public `ssoDomains/{domain}` doc maps a\n * verified email domain → `{ orgId, tenantId, providerId }` so the\n * pre-auth sign-in page can resolve an SSO org without reading the org doc.\n *\n * Security: a domain reaches `domains[]` ONLY by passing DNS TXT verification\n * (AGL-1210). This is the account-takeover guard, and it is the whole reason\n * self-serve is safe: without it an org could claim a domain it does not own,\n * `ssoDomains/{domain}` would route that domain's sign-ins to its IdP, and it\n * would intercept another company's logins. Claims in flight live in\n * `orgs/{orgId}/ssoDomains/{domain}` (see `OrgSsoDomainClaim`) and are NOT\n * governed until verified.\n *\n * `enforced` blocks password/social login for the governed domains, so it is a\n * lockout risk and is rehearsed (`previewSsoEnforcement`) before it is applied.\n */\nexport interface OrgSsoConfig {\n /** GCIP tenant id that carries this org's IdP provider. */\n tenantId: string\n /** GCIP provider id, e.g. `saml.aglyn-workspace` or `oidc.acme`. */\n providerId: string\n /** IdP protocol. Phase 1 ships SAML; OIDC is Phase 2. */\n protocol: 'saml' | 'oidc'\n /** Human label for the IdP (shown on the SSO button + staff card). */\n displayName?: string\n /**\n * Email domains routed to this IdP (lowercased, no `@`). A domain is added\n * here only after its claim passes DNS TXT verification, and removed the\n * moment re-verification fails — so membership of this array IS the\n * \"ownership proven\" statement. Never write to it from anywhere but the\n * verification path.\n */\n domains: string[]\n /**\n * Retained for `sso-jit`, which gates on it. Kept in lockstep with\n * `domains.length > 0` by the verification path; it was a staff-attested\n * boolean before AGL-1210 and is now derived, never asserted by a human.\n */\n domainVerified: boolean\n /**\n * SAML metadata the CUSTOMER supplies about their IdP. Stored so the pool's\n * provider config can be rebuilt or re-applied without asking again. The\n * X.509 certificate is a public signing certificate, not a secret.\n */\n idp?: {\n entityId: string\n ssoUrl: string\n certificates: string[]\n }\n /**\n * Require SSO for the governed domains — disables password/social login for\n * them (Phase 2). Phase 1 keeps this false so users keep a fallback.\n */\n enforced: boolean\n /** Lifecycle: `configuring` (not live), `active`, or `disabled`. */\n status: 'configuring' | 'active' | 'disabled'\n /**\n * Uid + time of the last config change. Since AGL-1210 this is normally an\n * ORG ADMIN, not staff — the flow is self-serve end to end.\n */\n configuredBy?: string\n configuredAt?: ITimestamp\n}\n\n/**\n * A pending or proven claim on one email domain (AGL-1210), stored at\n * `orgs/{orgId}/ssoDomains/{domain}`.\n *\n * A subcollection rather than a map on the org doc because domains contain\n * dots, and a dotted key in a Firestore map is read as a nested field path by\n * every update helper — writing `sso.domainClaims[\"acme.com\"]` would silently\n * create `{acme: {com: …}}`. A document id has no such ambiguity.\n *\n * The claim is deliberately worthless on its own: holding one grants nothing.\n * Only `verified` moving to true adds the domain to `sso.domains`, and only\n * that array is consulted at sign-in.\n */\nexport interface OrgSsoDomainClaim {\n /** The domain being claimed (lowercased, no `@`); mirrors the document id. */\n domain: string\n /**\n * Random value the org must publish as a DNS TXT record at\n * `_aglyn-challenge.<domain>`. Per org+domain, so two orgs claiming the same\n * domain get different tokens and neither can pass on the other's record.\n */\n token: string\n /** True once a DNS lookup has actually seen `token` at the challenge host. */\n verified: boolean\n createdAt: ITimestamp\n verifiedAt?: ITimestamp\n /** Last lookup attempt, successful or not — drives re-verification. */\n lastCheckedAt?: ITimestamp\n /**\n * TXT records seen on the last FAILED lookup. Shown back to the customer,\n * because \"no record found\" and \"found the wrong value\" are different\n * mistakes and the fix differs.\n */\n lastRecords?: string[]\n}\n\n/**\n * Effective limits/gates for a tenant. Plan defaults come from\n * `PLAN_ENTITLEMENTS` (versioned with the app); per-tenant overrides can be\n * stored on the tenant doc and win over the plan defaults.\n *\n * The CORE half. Read {@link OrgEntitlements} instead: it is this plus\n * whatever the plugins declare. The keys spelled out below predate the seam\n * and move to their plugins with the rest of AGL-3080; every NEW key is\n * declared by whoever owns it.\n */\nexport interface CoreOrgEntitlements {\n hostLimit?: number\n screensPerHost?: number\n sharedLayoutsPerHost?: number\n /** Saved templates per host (AGL-666) — includes marketplace downloads. */\n templatesPerHost?: number\n // NOTE (AGL-658): \"add-on\" means two unrelated things in this codebase.\n // `seatAddons` below are BILLING capacity — extra managers, hosts, seats —\n // surfaced in the UI as \"plan add-ons\". The marketplace sense (installed\n // plugins, the `orgAddons` slot) is a different concept entirely. The\n // marketplace owns the bare word; billing copy always qualifies it.\n // Firestore and Stripe lookup keys stay as they are — they are persisted.\n storagePerHostMb?: number\n /**\n * RETIRED (AGL-2133). Enforced by nothing and unreachable by measurement —\n * see `RETIRED_ENTITLEMENT_KEYS`. It stays on the TYPE because live org\n * documents still carry staff overrides of it and `OrgEntitlements` is the\n * shape those documents are read through; `resolveOrgEntitlements` drops\n * it, and no plan declares it any more, so nothing can resolve a value.\n * @deprecated\n */\n totalSiteSizeMb?: number\n /**\n * Included per-site COLLABORATOR seats (`hosts/{id}/members`,\n * viewer/editor/admin) — console teammates scoped to one site, not\n * end-user member accounts (`siteMembers`), which are unlimited on\n * every plan (AGL-888/889). Legacy key name; persisted, do not rename.\n */\n membersPerHost?: number\n /** Seat model (AGL-112): included tenant-manager seats. */\n managersPerOrg?: number\n /** Hard seat caps incl. purchased addons; beyond these, upgrade the plan. */\n maxManagersPerOrg?: number\n /** Hard per-site collaborator cap incl. addons (see `membersPerHost`). */\n maxMembersPerHost?: number\n bandwidthGb?: number\n /**\n * Saved form DEFINITIONS per host — documents under\n * `hosts/{hostId}/forms`, the entity a submission's `formId` points at.\n *\n * Not the same dimension as `formSubmissionsPerMonth`, and the two are\n * routinely confused: this is how many distinct intake forms an author may\n * build, that is how many replies the site may accept. Only the second is\n * tiered. This one resolves to the same `FORMS_PER_HOST_CEILING` on every\n * plan that has the form entity at all — it bounds a collection rather than\n * selling a tier — and exists as an entitlement so that `checkQuota` can\n * refuse a create, and so a contract can override one org's number.\n *\n * Counts the CATALOG, never the traffic. A `Form` node drawn on a page and\n * left unbound submits without a definition and spends nothing here, which\n * is why a plan resolving to 0 still accepts submissions.\n */\n formsPerHost?: number\n /**\n * Reusable component DEFINITIONS per host — documents under\n * `hosts/{hostId}/components` (AGL-3615). Free 1; every paid plan\n * `UNLIMITED`, the allowance it had when components were a boolean\n * feature. Refused at the create only, so a site holding more than its\n * plan now includes keeps every component.\n */\n componentsPerHost?: number\n /** Component-builder caps (AGL-99): host variables. */\n variablesPerHost?: number\n /** Component-builder caps (AGL-99): host functions. */\n functionsPerHost?: number\n /** Workflow builder cap (AGL-99/101). */\n workflowsPerHost?: number\n /** Bookable services per host (AGL-159). */\n servicesPerHost?: number\n /** Redirect rules per host (AGL-154). */\n redirectsPerHost?: number\n /**\n * The CRM RECORDS band (AGL-197, widened in AGL-2611): contacts, companies\n * and deals, counted together across the org. The persisted key still says\n * \"contacts\" because it is written on live org documents as a staff\n * override and read back through this type; every customer surface says\n * \"CRM records\". A tier with an `extraContactsUsdPer1k` rate meters past\n * it; a tier with none refuses the next record. Free has no CRM\n * (AGL-2851), so on Free the record its band refuses is a capture. Tasks\n * and activities are not counted — see `CRM_ACTIVITIES_PER_RECORD_CEILING`.\n */\n contactsPerHost?: number\n /**\n * CAMPAIGN emails sendable per calendar month (AGL-161), and campaign\n * emails only (AGL-1438).\n *\n * Transactional mail — password resets, invites, order confirmations,\n * booking reminders, workflow notifications — is never refused by this cap\n * at any tier. It still COUNTS toward the org's email cost meter; it simply\n * cannot be blocked, because a quota that can drop a password reset locks\n * somebody out of their own account and a dropped order confirmation reads\n * to the buyer as a failed order.\n *\n * The name is the narrow thing on purpose. A cap that means something\n * narrower than it says is how AGL-1438 came to exist, so every surface\n * that shows this number says \"campaign\".\n *\n * Sized against shared-domain reputation, which is a different principle\n * from the one behind `crmEmailsPerDay` — see there for why the two ladders\n * are not meant to track each other.\n */\n emailSendsPerMonth?: number\n /** Action runs per calendar month (AGL-148). */\n actionRunsPerMonth?: number\n /** Included customer REST API requests per calendar month (AGL-634);\n * Beyond it, metered overage per 1,000 where the plan prices it. Only\n * Business/Advanced carry `apiAccess`, so lower tiers are 0. */\n apiRequestsPerMonth?: number\n /** Dynamic data caps — org-scoped (AGL-239/240): datasets are shared\n * by every host in the org, so counts and size meter per org. */\n datasetsPerOrg?: number\n /** Hard dataset cap incl. addons (AGL-132/240); beyond it, upgrade. */\n maxDatasetsPerOrg?: number\n recordsPerDataset?: number\n /** Included aggregate dataset storage (MB) across the org (AGL-240);\n * Beyond it, metered overage per GB where the plan prices it. */\n dataStorageMbPerOrg?: number\n /** @deprecated Legacy host-keyed override (pre-AGL-240); resolved into\n * `datasetsPerOrg` by `resolveOrgEntitlements`. */\n datasetsPerHost?: number\n /** @deprecated Legacy host-keyed override (pre-AGL-240); resolved into\n * `maxDatasetsPerOrg` by `resolveOrgEntitlements`. */\n maxDatasetsPerHost?: number\n /** Catalog products per host (AGL-278). */\n productsPerHost?: number\n /** Inventory locations per host (AGL-286). */\n inventoryLocations?: number\n /** Concurrent POS registers (AGL-312); add-ons raise it (AGL-329). */\n posRegisters?: number\n /** Platform fee % on physical storefront sales (Connect app fee). */\n transactionFeePhysicalPct?: number\n /** Platform fee % on digital storefront sales (Connect app fee). */\n transactionFeeDigitalPct?: number\n features?: OrgFeatureFlags\n /**\n * A staff PLAN COMP (AGL-3034) — see `OrgPlanComp`. Not a quota: the\n * resolver's numeric loop skips it, and `ResolvedOrgEntitlements` omits\n * it. Read through `readOrgPlanComp`, never directly.\n */\n planComp?: OrgPlanComp\n}\n\n/**\n * Effective limits and gates for a tenant, the platform's and its plugins'\n * together (AGL-3124).\n *\n * {@link CoreOrgEntitlements} is what `PLAN_ENTITLEMENTS` declares per plan\n * and what `ResolvedOrgEntitlements` keeps exhaustive.\n * {@link PluginEntitlementQuotas} is the plugin half: a plugin declares a\n * key of its own into it with `declare module`, and its figure per plan comes\n * from that plugin's `plan-entitlements` declaration, compiled into\n * `PLAN_ENTITLEMENTS` (AGL-3080), widened by any seat add-on it declares. So\n * `resolveOrgEntitlements` carries a plugin's band, and `keyof\n * OrgEntitlements` admits its key, without this file naming the plugin's\n * domain.\n */\nexport interface OrgEntitlements\n extends CoreOrgEntitlements,\n PluginEntitlementQuotas {}\n\n/**\n * A plan staff GRANTED a workspace that no live subscription pays for\n * (AGL-3034), stored at `orgs/{orgId}.entitlements.planComp`.\n *\n * ## Why a marker of its own, and not the stored `plan`\n *\n * `plan: 'pro'` beside `billingStatus: 'canceled'` is what EVERY customer who\n * canceled can look like: Stripe's mirror is not always walked back to\n * `free`, and a staff override used to write `plan` directly. So a stored\n * paid plan must never outrank a dead subscription — `resolveEffectivePlan`\n * reads it as Free, which is AGL-247's rule and stays it. A comp is a\n * different fact with a different writer: only `/api/admin/org-override`\n * writes this, audited, with a reason. Nothing reads a comp out of `plan`.\n *\n * ## When it applies\n *\n * Only while the subscription is dead or absent — `resolvePlanComp`. A live\n * subscription (and any status the resolver does not read as dead) governs\n * the plan, and the comp waits, dormant, until staff remove it or the\n * subscription ends.\n *\n * ## Why under `entitlements`\n *\n * It sits beside the other staff overrides, on the one map the override\n * route already owns and the rules already deny to every client, staff\n * included (AGL-1795). A top-level key would have needed a new entry in all\n * three of the org rule's deny-lists, in a rules file at its size limit.\n *\n * ## What it is NOT\n *\n * Not a sale. A comp bills nothing, so every metered band on it is a wall —\n * `resolvePlanPricing` — and it is never a card on file or a paying\n * subscription: `isBillingSubscription` stays false, and the AI overage\n * charge path has no rate to bill it at.\n *\n * ## Capped or uncapped (AGL-3049)\n *\n * A comp is CAPPED unless staff lift it: every band is its plan's figure, a\n * hard limit, and a per-org quota override beside the comp raises one band.\n * An UNCAPPED comp (`uncapped: true`) reads every band and quota as\n * unlimited while it is in force — the shape an internal workspace needs —\n * and still bills nothing. The flag is part of the grant: the same route\n * writes it with its own reason and audit row, removing the comp removes it,\n * and a live subscription ignores it along with the rest of the comp.\n *\n * The route states it on every grant, `false` included. The org write is a\n * merge, which writes a nested map key by key, so a capped grant that left\n * the key out would keep the `true` of the uncapped comp it replaced.\n *\n * Typed loosely where foundation cannot import the vocabulary: `reason` is\n * an `OrgOverrideReasonCode` (`app-utils/org-override-reason`), narrowed by\n * `readOrgPlanComp`.\n */\nexport interface OrgPlanComp {\n /** The plan granted. A paid plan; a comp of `free` is not a comp. */\n plan: OrgPlan\n /**\n * Every band and quota reads as unlimited while the comp is in force\n * (AGL-3049). Only a literal `true` lifts them; absent means capped.\n */\n uncapped?: boolean\n /** Why, from the override's fixed reason set (AGL-1652). */\n reason: string\n /** Staff-only rationale; explicit `null` when none was given. */\n note: string | null\n /** The staff uid that granted it. */\n grantedBy: string\n /** When it was granted — the server's clock. */\n grantedAt: ITimestamp | null\n}\n\n/**\n * Paid addon quantities (AGL-112/524) purchased on top of the plan's\n * included allowances, billed as items on the org's Stripe subscription.\n * Seat/dataset kinds resolve as `included + purchased` clamped to the\n * plan's hard max — beyond the max the org must upgrade. Purchases only\n * count while the subscription is alive (they bill on it); staff grants\n * live on `entitlements` overrides instead, so the two never collide.\n */\nexport interface OrgSeatAddons {\n /** Extra tenant-manager seats. */\n managers?: number\n /**\n * Extra per-site collaborator seats. Legacy key name (AGL-888): the Stripe\n * price env suffix is still `EXTRA_MEMBER` and existing org docs carry this\n * key — persisted, do not rename.\n *\n * A POOL, not a raise (AGL-2439) — the same correction `posRegisters`\n * received in AGL-1775, applied to the key that never got it.\n * `membersPerHost` is enforced PER SITE, so folding this org-wide quantity\n * into the org-level entitlement handed every site the whole purchase: one\n * extra collaborator seat bought 20 collaborators on a 20-site org. Since\n * AGL-2439 the quantity here is the size of an org-level pool and\n * `collaboratorAllocations` says which site each purchased seat is assigned\n * to. Nothing reads this as a per-site number; use\n * `checkHostCollaboratorQuota` / `resolveHostCollaboratorCap`.\n */\n members?: number\n /** Extra org datasets (AGL-132/240); billed monthly per dataset. */\n datasets?: number\n /** Extra sites beyond the plan's `hostLimit` (AGL-68/524). */\n hosts?: number\n /**\n * Extra POS registers beyond the plan's `posRegisters` (AGL-329/524).\n *\n * A POOL, not a raise (AGL-1775). `posRegisters` is enforced PER SITE, so\n * folding this quantity into the org-level entitlement handed every site\n * the whole purchase — one $89/mo register bought 20 registers on a\n * 20-site org. Since AGL-1775 the quantity here is the size of an org-level\n * pool and `registerAllocations` says which site each purchased seat is\n * assigned to. Nothing reads this as a per-site number; use\n * `resolveHostRegisterCap`.\n */\n posRegisters?: number\n /** Event Calendar org-wide toggle, 0/1 (AGL-145/524). */\n eventCalendar?: number\n /**\n * Aglyn AI org-wide toggle, 0/1 (AGL-2896). One purchase covers every\n * host in the org, the way Event Calendar does: `resolveOrgEntitlements`\n * reads any quantity >= 1 as one, adds the plan's\n * `AI_ADDON_CREDITS_PER_MONTH` band to `assistCreditsPerMonth`, and\n * switches `features.aiGenerative` and `features.aiAssist` on. Priced per\n * plan at `PlanPricing.aiAddonMonthlyUsd`; `null` there means the plan\n * does not sell it.\n */\n aiAddon?: number\n}\n\n/**\n * Which SITE each purchased POS register seat is assigned to (AGL-1775):\n * `{ [hostId]: seats }`, drawn from the org-level pool\n * `seatAddons.posRegisters`.\n *\n * WHY IT LIVES ON THE ORG DOC. The pool is org-level and the entitlement it\n * modifies is resolved from this same document, so a caller that can resolve\n * entitlements at all already holds the allocation — there is no second read\n * to fail and no separate loading state in which a host could resolve to\n * something other than its plan cap. That property is the point: the\n * `checkQuota(undefined)` = Free-tier lesson inverted, where an absent\n * allocation must mean the PLAN's cap and never the pooled total.\n *\n * AN ENTITLEMENT INPUT, and therefore Admin-SDK-only — it is denied to every\n * client in `cloud/firebase-firestore.rules` alongside `seatAddons` and\n * `entitlements`. A client that could write this could assign itself the\n * whole pool on every site, which is the defect AGL-1775 exists to close.\n *\n * A host id absent from the map holds ZERO purchased seats and resolves to\n * the plan cap alone. Deleting a site deletes its key, which returns its\n * seats to the pool — the pool is `seatAddons.posRegisters` minus the sum of\n * this map, so a released key is available capacity by arithmetic rather\n * than by a separate counter that could drift.\n */\nexport type OrgRegisterAllocations = Record<string, number>\n\n/**\n * Which SITE each purchased COLLABORATOR seat is assigned to (AGL-2439):\n * `{ [hostId]: seats }`, drawn from the org-level pool `seatAddons.members`.\n *\n * The same object as `OrgRegisterAllocations` and for the same reasons —\n * see that type's block, all of which applies verbatim. It is a distinct type\n * because it is a distinct field on the document and the two pools are bought\n * separately; nothing may read one as the other.\n *\n * AN ENTITLEMENT INPUT, and therefore Admin-SDK-only — denied to every client\n * in `cloud/firebase-firestore.rules` alongside `seatAddons`,\n * `registerAllocations` and `entitlements`. A client that could write this\n * could assign itself the whole pool on every site, which is the defect the\n * pool exists to close.\n *\n * A host id absent from the map holds ZERO purchased seats and resolves to\n * the plan cap alone. Deleting a site deletes its key, returning its seats to\n * the pool by arithmetic rather than by a counter that could drift.\n *\n * ONE THING DIFFERS FROM REGISTERS and it is deliberate: collaborator seats\n * clamp to the plan's `maxMembersPerHost` band, because that is how they are\n * sold. Assigning ten pool seats to a Starter site cannot lift it past ten —\n * `resolveHostCollaboratorCap` applies the clamp, so the console must not\n * present an assignment past the band as capacity.\n */\nexport type OrgCollaboratorAllocations = Record<string, number>\n\n/**\n * The monthly storage-overage spend cap an org CHOSE for itself (AGL-1886,\n * corrected 2026-08-18).\n *\n * The ceiling on metered spend is the END USER's control: it exists to prevent\n * a surprise, not to gate the product on a customer's acknowledgement.\n *\n * So this document is **absent by default and absent is the normal state**.\n * Storage past a metered plan's included band bills without it; the customer\n * is warned by `usage-alerts` on approach and at the band. Writing a cap here\n * is a customer opting IN to being stopped, and it is the only thing that can\n * refuse an upload on a plan that meters.\n *\n * NOT a consent record. As an acknowledgement that must exist before a paying\n * org may store a byte past its band, it costs revenue and blocks customers\n * without preventing any bill — `report-usage` bills stored bytes and never\n * reads this document.\n *\n * Still an ENTITLEMENT INPUT in the security sense, in the other direction: a\n * member who could raise their own `capUsd` could raise their own spend\n * ceiling, so the rules deny it to every client and only\n * `/api/billing/storage-overage` (Admin SDK, `billing.manage`) writes it.\n *\n * @see apps/console/utils/storage-overage.ts for the model and what still\n * hard-bands.\n */\nexport interface OrgStorageOverage {\n /**\n * The monthly storage-overage spend the org will not go past. Present means\n * capped; absent means uncapped, which is the default. A present but\n * malformed value resolves to `STORAGE_CAP_FALLBACK_USD` rather than to \"no\n * cap\" — a customer who asked for a ceiling must not be billed past one\n * because the stored number was corrupt.\n */\n capUsd?: number\n /** When the cap was set, for the audit trail. */\n capSetAt?: ITimestamp | null\n /** The uid that set it. */\n capSetBy?: string | null\n /**\n * LEGACY (pre-2026-08-18): the acknowledged-consent pair. Read only, and\n * honoured as a cap of `monthlyCeilingUsd` so a ceiling somebody typed is\n * not silently raised. Never written again.\n */\n acknowledgedAt?: ITimestamp | null\n /** @deprecated legacy consent field; see `acknowledgedAt`. */\n acknowledgedBy?: string | null\n /** @deprecated legacy bound; read as a cap. See `capUsd`. */\n monthlyCeilingUsd?: number\n}\n\n/**\n * The org's own answer to what happens when Aglyn Assist reaches its included\n * band (AGL-2653).\n *\n * Absent by default, and absent is the normal state: assist past the band is\n * SOLD — `reserveAssistMessage` keeps reserving and `report-usage` bills the\n * credits past the band at `PLAN_PRICING.extraAssistCreditsUsdPer1k`. Writing\n * `hardCap: true` is a customer opting IN to being stopped at the band\n * instead, which is what the gate did for every org before the overage was\n * sold. The shape mirrors `OrgStorageOverage`, the same control on storage,\n * for the same reason: a ceiling on metered spend is the END USER's control,\n * offered rather than imposed.\n *\n * An ENTITLEMENT INPUT in the security sense, in both directions at once. A\n * client that could clear it would lift a ceiling the org chose; one that\n * could set it on another org would switch that org's assistant off at the\n * band. So the rules deny it to every client and only\n * `/api/billing/assist-overage` (Admin SDK, `billing.manage`) writes it. Read\n * through `resolveAssistHardCap` in the AI plugin's `usage/assist-credits`,\n * never directly.\n *\n * `report-usage` never reads this map — it bills whatever landed past the\n * band, the way storage bills stored bytes. With the cap on that is at most\n * the one exchange that crossed the line, because an exchange's cost is known\n * only after it is answered; with it off, it is the overage the org chose to\n * buy. Reading the switch at sweep time instead would let a flip on the 1st\n * erase a month's overage, the AGL-2399 shape on a different meter.\n *\n * On a plan whose `extraAssistCreditsUsdPer1k` is `null` the switch changes\n * nothing: there is no rate to sell past the band at, so the band stays the\n * wall it always was, on or off.\n *\n * ## The dollar ceiling beside the switch (AGL-2898)\n *\n * `capUsd` is the second control, and it answers a different question. The\n * switch asks \"sell past the band at all?\"; the ceiling asks \"and if so, how\n * much?\" — a monthly figure in USD of OVERAGE, priced at the plan's rate, past\n * which the assistant refuses for the rest of the month. It is the\n * `storageOverage.capUsd` of this meter: optional, absent by default, and the\n * customer's own number rather than a band the plan sold them. Read through\n * `resolveAssistOverageCapUsd`, and enforced by `assistOverageCapReached`\n * inside the same reservation transaction as the band.\n *\n * The two are independent. With the switch on the ceiling is never reached,\n * because nothing past the band is ever sold; with the switch off and no\n * ceiling the overage is open-ended, bounded only by the plan's message cap.\n * On a plan with no rate the ceiling, like the switch, changes nothing:\n * overage on such a plan prices to zero and zero never reaches a ceiling.\n */\nexport interface OrgAssistOverage {\n /** True stops assist at the included band; absent or false sells past it. */\n hardCap?: boolean\n /** When the switch was last written, for the audit trail. */\n hardCapSetAt?: ITimestamp | null\n /** The uid that wrote it. */\n hardCapSetBy?: string | null\n /**\n * The month's ceiling on AI overage, in USD at the plan's rate. `null` or\n * absent is no ceiling; anything that is not a finite positive number reads\n * as none too, so a corrupt value cannot become a wall of `NaN`.\n */\n capUsd?: number | null\n /** When the ceiling was last written or cleared, for the audit trail. */\n capSetAt?: ITimestamp | null\n /** The uid that wrote it. */\n capSetBy?: string | null\n}\n\n/**\n * A discount applied to the org's OWN Aglyn subscription (AGL-1105) — a\n * comped enterprise deal or a redeemed coupon, mirrored from a Stripe coupon\n * so `orgMonthlyRevenueUsd` can report net-of-discount MRR without a Stripe\n * round-trip. Exactly one of `percentOff` / `amountOffUsd` is set, matching\n * the Stripe coupon it points at (Stripe coupons are one or the other).\n * DISTINCT from a storefront/customer discount — this is Aglyn's own bill.\n */\nexport interface OrgDiscount {\n /** The Stripe coupon id (`co_…`) applied to the subscription. */\n couponId: string\n /** The Stripe promotion code id (`promo_…`), when the coupon has a code. */\n promotionCodeId?: string\n /** The human redemption code (e.g. `LAUNCH25`), when one exists. */\n code?: string\n /** Percentage off, 0–100 (mutually exclusive with `amountOffUsd`). */\n percentOff?: number\n /** Fixed USD off per invoice (mutually exclusive with `percentOff`). */\n amountOffUsd?: number\n /** The staff uid that applied it (audit trail; self-serve reads `system`). */\n appliedBy: UserUid\n /** Free-text why (the enterprise deal, the promotion) — staff-only. */\n reason?: string\n appliedAt: ITimestamp\n}\n\nexport interface OrgSubscription {\n status?:\n | 'active'\n | 'trialing'\n | 'past_due'\n | 'canceled'\n | 'incomplete'\n | 'unpaid'\n /**\n * WHY the subscription ended, mirrored from Stripe's\n * `cancellation_details.reason` on `customer.subscription.deleted`\n * (AGL-1877). `'payment_failed'` is Stripe having exhausted the dunning\n * retries; `'cancellation_requested'` is the customer leaving on purpose.\n *\n * The distinction is not cosmetic — it is the only thing that tells a\n * DELINQUENT workspace apart from one that simply left, and both arrive as\n * the same event with the same `status: 'canceled'` and the same\n * `plan: 'free'` mirror. Without it `shouldAutoLockOrgForBilling` had no\n * reachable delinquent state at all (see `billing-auto-lock.ts`), the\n * churn funnel counted a dunning failure as voluntary churn, and nobody\n * could be told which had happened.\n *\n * Absent on every org whose subscription ended before this shipped, and\n * every consumer must fail CLOSED on that — an unknown reason is not a\n * proven payment failure.\n */\n canceledReason?: string | null\n priceId?: string\n /**\n * Billing interval of the plan item (AGL-532), webhook-mirrored: the\n * Billing page initializes its monthly/annual toggle from it and plan\n * switches keep it unless the toggle says otherwise.\n */\n interval?: 'month' | 'year'\n currentPeriodEnd?: ITimestamp\n /**\n * A downgrade scheduled for the current period end (AGL-1862). Set by\n * `/api/billing/subscription` when a switch walks DOWN the self-serve\n * ladder — the Stripe subscription schedule owns the transition, this is\n * the manager-gated mirror the billing page renders. `null` (not absent)\n * once released, so a merge clears it. Rides inside `subscription` because\n * `pickOrgBillingFields` drops any other top-level key.\n */\n pendingDowngrade?: {\n plan: string\n interval: 'month' | 'year'\n /** ISO timestamp of the period end the schedule flips at. */\n effectiveAt: string | null\n scheduleId: string\n } | null\n /**\n * Negotiated custom price as a **monthly-normalized** USD figure (AGL-1110),\n * set when an enterprise org bills at an ad-hoc amount rather than a plan's\n * list price — e.g. `agency` capability at $2,730/mo. An annual custom deal\n * is stored ÷12 here (the yearly total lives on the Stripe price), so every\n * reader treats it as monthly and never re-divides. Webhook-mirrored from the\n * Stripe subscription's recurring price. When present it is the truth for\n * revenue — `orgListPriceMonthlyUsd`/`orgMonthlyRevenueUsd` use it instead of\n * the plan default (fixes the custom-price MRR under-report, AGL-1110).\n */\n customMonthlyUsd?: number\n}\n\n/**\n * The org's billing/entitlement doc shape — the view of `orgs/{orgId}`\n * that `useCurrentOrg()`, the plugin-page `org` prop, and the\n * entitlement resolvers carry. (Formerly `AglynTenant`; the alias was\n * removed in AGL-444.)\n */\n/**\n * `orgs/{orgId}.bandwidthCap` — the engaged free-plan bandwidth cap\n * (AGL-1967/2070/2155).\n *\n * STAMPED WITH THE MONTH IT IS FOR, and never cleared. A new month simply\n * stops matching, so the cap lifts on its own with no write: a marker whose\n * removal depends on a cron running is a marker that stays engaged when the\n * cron fails, which would take a customer's site down over an infrastructure\n * problem rather than over their traffic.\n *\n * Plain numbers rather than `ITimestamp`, matching `suspendedUntilMs`, so the\n * value survives the tenant's cache serialization unchanged.\n */\nexport interface OrgBandwidthCap {\n /** UTC `YYYY-MM` this cap was engaged for. Only the current month refuses. */\n month: string\n /** When the sweep engaged it. Diagnostic only; nothing gates on it. */\n engagedAt?: number\n /** The org-wide page views measured at engage time. Diagnostic. */\n pageViews?: number\n /** The band those page views were measured against. Diagnostic. */\n includedPageViews?: number\n}\n\n/**\n * The organization document.\n *\n * NOT composed from a plugin-augmented interface, deliberately (AGL-3124).\n * `org-write-deny-coverage.spec.ts` enumerates this interface's fields by\n * reading THIS FILE'S SOURCE, and checks every one of them against the\n * Firestore write-deny rules; a field a plugin declared from its own file\n * would be invisible to that sweep, so composing here would open a rules\n * coverage hole to save a plugin an already-solved problem. A plugin's own\n * settings block goes through `registerPluginConfigSchema` into\n * `pluginSettings/{pluginId}`, which is its own document under its own rule.\n */\nexport interface AglynOrgBilling extends AglynDocument {\n /** The document id, injected by the reader — never a stored field. */\n $id: OrgUid\n /**\n * The org's display name, written by `createOrganization` and renamed\n * through /api/orgs/settings (the name is denormalized onto every\n * membership row, so the rename fans out server-side). Client-writable by\n * an org admin on purpose — see `ORG_CLIENT_WRITABLE_FIELDS`.\n *\n * Spelled `displayName` here until AGL-1355; nothing ever read that, and\n * the document has always carried `name`. The coverage guard derives the\n * org's field set from this interface, so a field that does not exist\n * bought nothing but a blind spot.\n */\n name?: string\n /** Free-text workspace description; nothing gates on it. */\n description?: string\n /**\n * The IANA zone this workspace's published dates read in (AGL-3237) —\n * `America/Chicago`, `Europe/Berlin`. Absent is UTC.\n *\n * What it decides is which CALENDAR DAY an instant is attributed to on a\n * published site, which is an editorial fact about the publisher: a post\n * that went out at 19:30 in Chicago is dated the 21st here and was dated\n * the 22nd while UTC decided it. Sites inherit it; see\n * `resolveSiteTimeZone`.\n *\n * SERVER-OWNED: written through /api/orgs/settings and denied to the\n * client SDK, because `resolveSiteTimeZone` reads it in `app-utils` and\n * the write-deny guard treats a resolver input as server-owned.\n * Nothing bills, gates or routes on it, and it is validated at every read\n * through `isSupportedTimeZone`, so an unusable value renders as UTC rather\n * than throwing inside a page render.\n */\n timeZone?: string\n /**\n * The sites this organization declared ONE SENDER, `{ [groupId]: { name,\n * hostIds } }` (`consent-groups.ts`). Absent, every site is alone.\n *\n * SERVER-OWNED: denied to the client SDK, because `consentGroupForHost`\n * reads it in `app-utils` and decides who a marketing basis covers and whose\n * opt-outs hold a send. The Emails hub's consent group editor changes it\n * through `/api/orgs/consent-groups`, and the one write is the executor's\n * declare step, taken only after every separating site's refusals have\n * been carried both ways (`consent-group-change.ts`).\n */\n consentGroups?: Record<string, { name: string; hostIds: string[] }>\n /**\n * A consent group change in flight (AGL-3320): `{ changeId, phase,\n * hostIds, startedAtMs, declaredAtMs? }`, present exactly while one runs\n * and cleared when it finishes or is canceled. The job it names is\n * `orgs/{orgId}/consentGroupChanges/{changeId}`.\n *\n * SERVER-OWNED: written only by the executor, which relies on it as the\n * lock — a second change, and a deletion of a site it names, are refused\n * while it stands — so a client able to clear it could run two changes at\n * once, and one able to set it could block every change after it.\n */\n consentGroupsChange?: {\n changeId: string\n phase: 'carry' | 'rehome' | 'sweep'\n hostIds: string[]\n startedAtMs: number\n declaredAtMs?: number\n }\n /**\n * Whether a declared group's sites wait for each other's confirmation click\n * (AGL-3316). Absent or `false` is off: a pending confirmation holds only\n * the asking site's mail.\n *\n * SERVER-OWNED: written through /api/orgs/settings and denied to the client\n * SDK, for the reason `consentGroups` is — `consentGroupForHost` resolves it\n * into every group a send path reads.\n */\n consentGroupsAwaitConfirmation?: boolean\n /** The workspace URL segment, reserved through `orgSlugs/{slug}`. */\n slug?: string\n /**\n * The owner's uid. Spelled `ownerId` here until AGL-1355 — every reader\n * has always used `ownerUid` (the key `createOrganization` writes and the\n * rules deny), so the declared name was dead.\n */\n ownerUid?: UserUid\n hosts?: Record<HostUid, true>\n /** Subscription tier; missing/unknown plans resolve as `free`. */\n plan?: OrgPlan\n /** Per-org entitlement overrides (admin console); win over plan defaults. */\n entitlements?: OrgEntitlements\n /**\n * Per-org RELEASE-flag overrides (AGL-1635) — a different axis from\n * `entitlements`, which asks what the org's plan includes. These ask\n * whether an unreleased feature is switched on for this one customer, and\n * win over both the Remote Config value and the rollout bucket.\n *\n * Staff-only, and deliberately narrower than `entitlements`: super staff\n * alone may write it, matching the platform-wide flag editor\n * (`/api/admin/flags` is super-only), because forcing a flag on for an org\n * is the same class of act as flipping it for everyone — just scoped.\n * Read through `parseOrgReleaseFlagOverrides`, never directly.\n *\n * Typed loosely on purpose. The keys ARE `ReleaseFlagKey`, but that union\n * lives in `app-utils/release-flags` and foundation cannot import\n * app-utils (see `platform.types.ts`). Declaring the narrow type here\n * would also overstate what is on disk: this map outlives registry\n * renames, so a retired key is a thing that genuinely exists in Firestore.\n * `parseOrgReleaseFlagOverrides` is what narrows it, dropping unknown keys\n * and non-booleans.\n */\n releaseFlags?: Record<string, boolean>\n /**\n * White-label brand identity (White-Label Phase 1). Applied only when the\n * org carries the `whiteLabel` entitlement; read exclusively through\n * `resolveBrandingProfile`, never directly, so no surface diverges.\n */\n brandingProfile?: OrgBrandingProfile\n /**\n * Enterprise SSO config (AGL-1101). Applied only when the org carries the\n * `ssoEnabled` entitlement; the console routes sign-in through the org's\n * GCIP tenant/provider named here. See `OrgSsoConfig`.\n */\n sso?: OrgSsoConfig\n /** Per-org plugin switchboard (AGL-416); see plugin-manager/enabled-plugins. */\n enabledPlugins?: string[]\n /** Purchased addon seats (AGL-112); billed monthly per seat. */\n seatAddons?: OrgSeatAddons\n /**\n * POS register seats assigned out of the org pool (AGL-1775). An\n * ENTITLEMENT INPUT: it raises a per-site cap, so it is Admin-SDK-only.\n * @see OrgRegisterAllocations\n */\n registerAllocations?: OrgRegisterAllocations\n /**\n * Per-site collaborator seats assigned out of the org pool (AGL-2439). An\n * ENTITLEMENT INPUT: it raises a per-site cap, so it is Admin-SDK-only.\n * @see OrgCollaboratorAllocations\n */\n collaboratorAllocations?: OrgCollaboratorAllocations\n /**\n * The free plan's engaged BANDWIDTH CAP (AGL-1967/2070/2155), denormalized\n * onto this doc by the `usage-alerts` cron so the serving path can refuse a\n * capped site without a read of its own.\n *\n * An ENTITLEMENT INPUT in the strongest sense: it is the only thing standing\n * between a free site that has blown its band and unmetered egress, so a\n * client-writable value would let an org admin lift their own cap by\n * deleting one field. Admin-SDK only; denied in the rules.\n *\n * Read exclusively through `bandwidthCapEngaged` (`app-utils/bandwidth-cap`),\n * never directly — the marker on its own is not the answer. The resolver\n * re-derives the plan on every read, which is what lets an org that upgrades\n * mid-month start serving again without waiting for a cron to clear it.\n */\n bandwidthCap?: OrgBandwidthCap\n /**\n * The org's assist hard-cap switch (AGL-2653) — see `OrgAssistOverage`.\n * Admin-SDK-only, denied in the rules beside `storageOverage`; read\n * through `resolveAssistHardCap`.\n */\n assistOverage?: OrgAssistOverage\n stripeCustomerId?: string\n subscription?: OrgSubscription\n /**\n * Discount on the org's own Aglyn subscription (AGL-1105) — staff-applied\n * enterprise deal or a redeemed coupon. `orgMonthlyRevenueUsd` subtracts it\n * for net-of-discount MRR; `orgListPriceMonthlyUsd` ignores it (list price).\n */\n discount?: OrgDiscount\n /**\n * Explicit **comped enterprise** marker (AGL-1110). Makes the org read as\n * \"Enterprise\" (via `isEnterpriseOrg`) without a negotiated custom price —\n * for internal/dogfood accounts (e.g. Aglyn's own org) that carry full\n * Enterprise capability + SSO but are 100%-discounted, so they collect $0\n * while infra cost is still metered. Staff-set; distinct from a paying\n * custom-priced enterprise, which qualifies via `subscription.customMonthlyUsd`.\n */\n enterprise?: boolean\n /**\n * The bare `subscription.status` word the Stripe webhook mirrors back onto\n * this doc for the AGL-275 dunning banner and `resolveEffectivePlan`\n * (AGL-1028). An ENTITLEMENT INPUT: a dead subscription downgrades a paid\n * plan to free, so a client-writable value would restore the plan.\n */\n billingStatus?: string\n /** Staff suspension (AGL-202): set = all the org's sites serve 503. */\n suspendedAt?: ITimestamp | null\n suspendedReason?: string\n /**\n * Whether the suspension above is IN FORCE, stored so the staff\n * Organizations list can filter by it with an equality (AGL-3416). `false`\n * on every unsuspended org; written beside the `suspended*` family by the\n * lockdown core and cleared on a lapsed timed lock by\n * `settleLapsedSuspensions`. Never an enforcement input: every enforcing\n * reader asks the family itself.\n */\n suspended?: boolean\n /**\n * GDPR erasure request (AGL-206): hard deletion happens ONLY via `eraseOrg`\n * after a 7-day hold from this stamp — reached by the\n * `/api/admin/run-erasures` cron, or by hand with\n * tools/scripts/erase-tenant.mjs, which calls the same function (AGL-1481).\n */\n erasureRequestedAt?: ITimestamp | null\n /**\n * The CRM's organization-wide settings (AGL-2613), written from\n * CRM → Settings by an owner or admin with the client SDK — see\n * `ORG_CLIENT_WRITABLE_FIELDS`. One map rather than a key per setting, so\n * the section can grow without a rules change each time.\n */\n crm?: OrgCrmSettings\n /**\n * The plan the platform team has asked this workspace to move to\n * (AGL-3466) — see `OrgUpgradeProposal`.\n *\n * SERVER-OWNED: written only by /api/admin/org-upgrade-proposal (staff,\n * audited) and cleared by `writeOrgBilling` once a subscription is live,\n * both Admin SDK. Denied to every client in the rules, staff included,\n * because `standingUpgradeProposal` reads it in `app-utils` and the\n * proposal is the platform's statement to the customer, not theirs.\n */\n upgradeProposal?: OrgUpgradeProposal\n createdAt?: ITimestamp\n updatedAt?: ITimestamp\n}\n\n/**\n * The platform team's request that a workspace move to a paid plan\n * (AGL-3466).\n *\n * The separate step that follows an evaluation: a client handed a workspace\n * looks around first, on whatever the workspace already has, and is asked to\n * upgrade only once staff record this. While it stands and no subscription\n * is live, the workspace's billing managers see it on the org home and on\n * Billing, with the plan preselected. Nothing about it grants or bills.\n */\nexport interface OrgUpgradeProposal {\n /** A paid plan the workspace can buy itself — never `free` or `enterprise`. */\n plan: OrgPlan\n /** The staff member who proposed it. */\n proposedBy: string\n /** Epoch millis, so every cache serialization reads it back unchanged. */\n proposedAt: number\n /** An optional line from staff, shown with the proposal. */\n note?: string\n}\n\n/**\n * What the CRM lets an organization decide for every site at once\n * (AGL-2613). Every key is optional and off when absent: a setting that\n * has never been touched behaves as the product did before it existed.\n */\nexport interface OrgCrmSettings {\n /**\n * Create a company from a captured contact's work email domain when no\n * company the capturing site can see carries that domain. Off by default:\n * a company minted from every domain that ever submitted a form is a list\n * nobody asked for. Public mailbox domains never qualify either way.\n */\n autoCreateCompanies?: boolean\n /**\n * What a new contact, company, deal or task starts shared with\n * (AGL-3662), set apart from datasets and media. `'org'`: every site.\n * `'host'`: the site it came in on and its consent group. Unset reads the\n * org's `defaultResourceScope` — see `crmDefaultScopeOf` — and then\n * `'host'`.\n */\n defaultRecordScope?: 'org' | 'host'\n /**\n * Per-site settings, keyed by host id (AGL-2618). On the ORG document\n * rather than on each host document because the reader is the org-level\n * assignment pass — one read of one document answers every site's default\n * — and because the writer is the same owner-or-admin the rest of this\n * map admits, through the same client branch. A host document would need\n * its own rules clause for a key only the CRM reads.\n */\n hosts?: Record<string, OrgCrmHostSettings>\n /**\n * The assignment rules, in the order they are tried (AGL-2618). The first\n * rule whose every condition holds names the owner; the site's default\n * owner is the fallback when none does. Bounded by\n * `CRM_ASSIGNMENT_RULES_MAX` in the section that writes it.\n */\n assignmentRules?: OrgCrmAssignmentRule[]\n /** The round-robin pool and its pointer — see `OrgCrmRoundRobin`. */\n roundRobin?: OrgCrmRoundRobin\n}\n\n/** What one site decides for records captured on it (AGL-2618). */\nexport interface OrgCrmHostSettings {\n /**\n * The member every record captured on this site is handed to when no\n * assignment rule claimed it. Absent: the record stays unassigned, which\n * is what the product did before the setting existed.\n */\n defaultOwnerUid?: string\n}\n\n/**\n * One assignment rule (AGL-2618): `when` every named condition holds for a\n * capture, `assign` the record this way. A condition left out is not a\n * condition — a rule naming only a source matches every capture from that\n * source — and a rule naming nothing matches every capture, which is how a\n * catch-all is written. `source` is a `ContactSource`, typed as a string\n * here because the foundation cannot import the capture vocabulary; the CRM\n * module narrows it.\n */\nexport interface OrgCrmAssignmentRule {\n id: string\n when: {\n source?: string\n formId?: string\n emailDomain?: string\n tag?: string\n }\n assign: { memberUid: string } | { roundRobin: true }\n}\n\n/**\n * The round-robin pool (AGL-2618): the members handed records in turn, and\n * WHO GOT THE LAST ONE. The pointer is a uid rather than an index so that\n * editing the pool — a member added, removed or moved — never skips or\n * repeats anybody: the next record goes to whoever follows the last\n * recipient in the pool as it stands, and to the first member when the last\n * recipient is no longer in it. Advanced only by the server, inside the\n * transaction that writes the owner, so two captures landing together take\n * two different members.\n */\nexport interface OrgCrmRoundRobin {\n memberUids?: string[]\n lastAssignedUid?: string\n}\n\n/**\n * WHO OWNS EACH FIELD OF `orgs/{orgId}` (AGL-1355).\n *\n * AGL-1354 found four server-owned keys — `brandingProfile`, `sso`,\n * `discount`, `enterprise` — that the Firestore rules' org-update key diff\n * had drifted past, so an org admin holding nothing but the Firebase client\n * SDK could write them on any plan. The keys are closed. The MECHANISM that\n * opened them was a hand-maintained deny-list with nothing checking it, and\n * that is what these two maps close.\n *\n * The rule is DEFAULT-DENY, and it is enforced from the interface above:\n * `org-write-deny-coverage.spec.ts` reads every field declared on\n * `AglynOrgBilling`, and one that appears in neither the rules' deny-list nor\n * one of these maps FAILS THE BUILD, naming the field. Adding a field to the\n * interface therefore forces the ownership decision here, at the declaration,\n * on the same commit — instead of shipping client-writable and staying that\n * way until the next audit.\n *\n * So: add a field above, and either add it to the `hasAny([...])` list in\n * `cloud/firebase-firestore.rules` under `match /orgs/{orgId}` (server-owned:\n * anything an entitlement, a price, a routing decision or a staff judgement\n * reads) or add it below WITH A REASON. When in doubt, deny it — a field the\n * server writes through an Admin-SDK route loses nothing by being denied to\n * the client, and that is true of every key AGL-1354 closed.\n *\n * The entries are the fields an org admin may set from the client SDK. The\n * reason is mandatory and is the whole value of the map: it records that\n * someone decided, rather than that someone forgot.\n */\nexport const ORG_CLIENT_WRITABLE_FIELDS: Readonly<Record<string, string>> = {\n name:\n 'The workspace name. Deliberately writable by an org admin — the rules ' +\n 'admit the rename branch and `firestore-rules.test.mjs` asserts it still ' +\n 'succeeds. Cosmetic: no entitlement, price or routing decision reads it. ' +\n '(The console renames through /api/orgs/settings anyway, because the name ' +\n 'is denormalized onto every membership row and the fan-out is a server ' +\n 'job — but the client write is allowed and must stay allowed.)',\n description:\n 'Free-text workspace blurb. Nothing resolves, gates or bills on it, and ' +\n 'no server route owns it.',\n createdAt:\n 'Creation stamp seeded by `createOrganization`. Nothing gates on it; the ' +\n 'staff org list only displays it. Denying it would buy nothing and would ' +\n 'break the merge-writes below, which stamp the pair together.',\n updatedAt:\n 'Last-write stamp. Every client write that IS allowed sets it in the same ' +\n '`setDoc(..., { merge: true })` — the staff suspension, erasure-request ' +\n 'and plan-override cards all do — so denying it would deny those writes.',\n crm:\n 'The CRM settings map (AGL-2613): `autoCreateCompanies`, the per-site ' +\n 'default owner, the assignment rules and the round-robin pool ' +\n '(AGL-2618), and whatever the CRM → Settings section adds beside them. ' +\n 'Deliberately writable by an org owner or admin — the section writes it ' +\n 'client-direct by dotted path. No entitlement, price, routing decision ' +\n 'or staff judgement reads it; its readers are the contact capture door ' +\n 'deciding whether to mint a company record from an email domain and ' +\n 'whom to hand a new record to, which are choices about the org\\'s own ' +\n \"data that the org's managers are the right people to make. The pool's \" +\n 'pointer (`crm.roundRobin.lastAssignedUid`) is advanced by the server ' +\n 'inside the assigning transaction; a client that moved it would only ' +\n 'change who is next, never what anybody may see.',\n}\n\n/**\n * Fields declared on `AglynOrgBilling` that are NOT stored on the document,\n * and so cannot be written by anyone. Separate from the map above because\n * \"the client may set this\" and \"this is not a field\" are different\n * statements, and collapsing them would let a genuinely client-writable field\n * hide behind a synthetic one.\n */\nexport const ORG_UNPERSISTED_FIELDS: Readonly<Record<string, string>> = {\n $id: 'The document id, injected by the reader. Never written as a field.',\n}\n\n"],"names":["ORG_CLIENT_WRITABLE_FIELDS","name","description","createdAt","updatedAt","crm","ORG_UNPERSISTED_FIELDS","$id"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;CAgBC,GAu1CD;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,MAAMA,6BAA+D;IAC1EC,MACE,2EACA,6EACA,6EACA,8EACA,2EACA;IACFC,aACE,4EACA;IACFC,WACE,6EACA,6EACA;IACFC,WACE,8EACA,4EACA;IACFC,KACE,0EACA,kEACA,2EACA,4EACA,2EACA,2EACA,wEACA,0EACA,2EACA,0EACA,yEACA;AACJ,EAAC;AAED;;;;;;CAMC,GACD,OAAO,MAAMC,yBAA2D;IACtEC,KAAK;AACP,EAAC"}
|
|
@@ -133,17 +133,28 @@ export interface AglynOrganization extends AglynDocument {
|
|
|
133
133
|
suspended?: boolean;
|
|
134
134
|
erasureRequestedAt?: ITimestamp | null;
|
|
135
135
|
/**
|
|
136
|
-
* Scope applied to newly created datasets
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
136
|
+
* Scope applied to newly created datasets when nobody chooses (AGL-1048). `'org'` — the default
|
|
137
|
+
* and today's behavior — shares them with every site. `'host'` starts them
|
|
138
|
+
* private to the site they were created in, which is what an agency
|
|
139
|
+
* running client sites wants: safe by default rather than safe by
|
|
140
|
+
* discipline.
|
|
141
141
|
*
|
|
142
142
|
* Only meaningful when there IS a site in context. Created from the org
|
|
143
|
-
*
|
|
144
|
-
*
|
|
143
|
+
* Data page there is no host to scope to, so those stay `'org'` either way.
|
|
144
|
+
*
|
|
145
|
+
* New media follows `defaultMediaScope` and new CRM records
|
|
146
|
+
* `crm.defaultRecordScope` instead; each falls back to this only while it
|
|
147
|
+
* is unset, because this one field decided all three before AGL-3662.
|
|
145
148
|
*/
|
|
146
149
|
defaultResourceScope?: 'org' | 'host';
|
|
150
|
+
/**
|
|
151
|
+
* The same choice for new uploads and media folders (AGL-3662), set
|
|
152
|
+
* separately from datasets: an org can keep its rate card on one site
|
|
153
|
+
* while every site shares its photos. Unset reads `defaultResourceScope`
|
|
154
|
+
* — see `defaultMediaScopeOf` — which is what every org stored before the
|
|
155
|
+
* two were split.
|
|
156
|
+
*/
|
|
157
|
+
defaultMediaScope?: 'org' | 'host';
|
|
147
158
|
/** The CRM's organization-wide settings (AGL-2613) — see `OrgCrmSettings`. */
|
|
148
159
|
crm?: OrgCrmSettings;
|
|
149
160
|
/** The plan staff asked the workspace to move to (AGL-3466). */
|