@aglyn/aglyn 1.0.0-beta.249 → 1.0.0-beta.252
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/activity-labels.js +2 -0
- package/src/lib/app-utils/activity-labels.js.map +1 -1
- package/src/lib/app-utils/ad-audience.d.ts +42 -0
- package/src/lib/app-utils/ad-audience.js +42 -0
- package/src/lib/app-utils/ad-audience.js.map +1 -0
- package/src/lib/app-utils/business-profile.d.ts +4 -0
- package/src/lib/app-utils/business-profile.js +2 -0
- package/src/lib/app-utils/business-profile.js.map +1 -1
- package/src/lib/app-utils/console-routes.d.ts +9 -0
- package/src/lib/app-utils/console-routes.js +6 -0
- package/src/lib/app-utils/console-routes.js.map +1 -1
- package/src/lib/app-utils/docs-help-sections.generated.js +455 -451
- package/src/lib/app-utils/docs-help-sections.generated.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +28 -4
- package/src/lib/app-utils/docs-help.generated.js +80 -13
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +193 -67
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/health-report.js +11 -0
- package/src/lib/app-utils/health-report.js.map +1 -1
- package/src/lib/app-utils/image-edit.d.ts +77 -0
- package/src/lib/app-utils/image-edit.js +116 -0
- package/src/lib/app-utils/image-edit.js.map +1 -0
- package/src/lib/app-utils/operator-alerts.js +30 -0
- package/src/lib/app-utils/operator-alerts.js.map +1 -1
- package/src/lib/app-utils/release-flags.d.ts +1 -1
- package/src/lib/app-utils/release-flags.js +10 -0
- package/src/lib/app-utils/release-flags.js.map +1 -1
- package/src/lib/app-utils/webhook-delivery.js +2 -0
- package/src/lib/app-utils/webhook-delivery.js.map +1 -1
- package/src/lib/plugin-manager/feature-plugins.d.ts +39 -0
- package/src/lib/plugin-manager/feature-plugins.js +13 -1
- package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/release-flags.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Release flags (AGL-227): platform-level \"is this feature launched\"\n * gating backed by Firebase Remote Config. A separate axis from plan\n * entitlements (`plan-entitlements.ts`) — entitlements ask whether the\n * tenant's PLAN includes a feature, release flags ask whether the feature\n * is released to the public at all (or partially, via percentage rollout).\n * Staff bypass release flags everywhere but see a flagged warning.\n */\n\n/** Remote Config parameter keys for release-gated features. */\nimport type { OrgPlan } from '../foundation'\nimport { PLAN_LABELS, SELF_SERVE_PLANS } from './plan-entitlements'\nimport { PLATFORM_BRAND_NAME } from './platform-brand'\nimport {\n PLUGIN_RELEASE_FLAGS,\n type PluginReleaseFlagKey,\n} from './plugin-release-flags.generated'\n\n/**\n * Every release flag: the ones plugins declare (compiled from\n * plugins.config.json, AGL-3080) and the platform's own below.\n */\nexport type ReleaseFlagKey =\n | PluginReleaseFlagKey\n | 'release_addon_store'\n | 'release_native_checkout'\n | 'release_edit_bar'\n | 'release_assist'\n | 'release_video_uploads'\n | 'release_video_delivery'\n | 'release_ai_generative'\n | 'release_crm_assist_whole_record'\n\nexport interface ReleaseFlagDefinition {\n key: ReleaseFlagKey\n label: string\n description: string\n /**\n * Fallback verdict when Remote Config is unreachable (offline, blocked,\n * first paint before activate). MUST match the seeded value in\n * cloud/firebase-remoteconfig.template.json so environments without a\n * published template behave like the template intends.\n */\n defaultEnabled: boolean\n /** Host dashboard tab id (host-nav-tabs.ts) this flag hides, if any. */\n navTabId?: string\n}\n\n/** A release flag a plugin declares, as the generator compiles it. */\nexport type PluginReleaseFlagDefinition = ReleaseFlagDefinition & {\n key: PluginReleaseFlagKey\n}\n\n/**\n * The platform's own release flags: features no plugin owns.\n *\n * Every first-party plugin is release-flagged too (AGL-422) — the flag feeds\n * the plugin LOADER (console, published sites, API dispatch), not just nav\n * visibility, so staff can kill-switch a whole plugin platform-wide from the\n * Feature Flags page. Those flags are the plugins' own: each is defined by\n * its catalog row's `releaseFlagDefinition` in plugins.config.json and\n * compiled into `PLUGIN_RELEASE_FLAGS`, so adding a plugin edits nothing\n * here.\n */\nconst PLATFORM_RELEASE_FLAGS: readonly ReleaseFlagDefinition[] = [\n {\n key: 'release_addon_store',\n label: 'Add-on store',\n description:\n 'Self-serve add-on purchases on the Billing page: seats, datasets, ' +\n 'extra sites, POS registers, Event Calendar (AGL-524..531).',\n defaultEnabled: true,\n },\n // Released to every workspace 2026-10-06 (AGL-3606). Storefront-only since\n // the console's plan checkout stopped rendering Stripe Checkout at all. Still\n // the surface's kill switch, globally or per org: off, both storefront\n // checkout routes return the hosted redirect again.\n {\n key: 'release_native_checkout',\n label: 'In-page checkout',\n description:\n 'Storefront shoppers pay on the merchant\\u2019s own site: the Payment ' +\n 'Element, email, shipping address and method, and a live total with ' +\n 'tax open in place under the Buy or Checkout button, styled with the ' +\n 'site theme, instead of a redirect to checkout.stripe.com (AGL-1944). ' +\n 'Released to every workspace 2026-10-06 (AGL-3606). The webhook is ' +\n 'still the only thing that fulfils. Also gated on ' +\n 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, so a deployment without one keeps ' +\n 'the redirect; turning this off returns every storefront to the redirect.',\n defaultEnabled: true,\n },\n // Released to every site 2026-09-16 (AGL-3041). Still the whole surface's\n // kill switch, globally or for one org by override: off, the tenant slot\n // renders nothing, both token mints refuse, and `/api/edit-context` turns\n // away every token already issued.\n {\n key: 'release_edit_bar',\n label: 'Site admin bar',\n description:\n 'Admin bar on published sites: a signed-in editor jumps from a live ' +\n 'page straight into the Besigner to edit it. Released ' +\n '2026-09-16 (AGL-3041) for every site; turning it off hides the bar ' +\n 'and revokes every outstanding edit token at the verify site.',\n defaultEnabled: true,\n },\n {\n key: 'release_assist',\n label: `${PLATFORM_BRAND_NAME} Assist`,\n description:\n 'The in-console AI chat helper on every page (AGL-1860): docs-' +\n 'grounded answers with deep links, page-context guidance on Pro+. ' +\n 'ON in production, and gated on TWO published legal artifacts ' +\n '(AGL-1909), neither of which is a repo file — both are live besigner ' +\n 'pages, so publication is what satisfies them: (1) the privacy-policy ' +\n 'disclosure for stored Q&A, because the data loop records every ' +\n 'exchange org-scoped under orgs/{orgId}/assistExchanges; and (2) the ' +\n 'Anthropic row on /legal/subprocessors, which was deliberately ' +\n 'REMOVED on 2026-08-13 (subprocessorsV2a-20260813) on the premise ' +\n 'that no production key existed — so the page is not merely ' +\n 'incomplete without it, it is affirmatively wrong. NOTE the real ' +\n 'trigger is ANTHROPIC_API_KEY, not this flag: /api/ai/assist (the ' +\n 'besigner copy assistant, AGL-89/130/169) carries no release flag at ' +\n 'all and sends customer site content to Anthropic on the key plus a ' +\n 'Pro entitlement alone. Setting the key in production therefore makes ' +\n 'Anthropic a subprocessor whether or not this flag is ever flipped.',\n defaultEnabled: true,\n },\n // Ingress only, and with no staff preview on the server: a video a staff\n // session uploads into a customer's library serves on that customer's pages\n // like any other, so a grant goes to the org through its override.\n // `apps/console/utils/server/video-uploads.ts` holds the gate.\n {\n key: 'release_video_uploads',\n label: 'Video uploads',\n description:\n 'New video in the media library, on every upload path (AGL-2830). ' +\n 'Off: refused with 403 video_uploads_paused, as DAM video delivery is ' +\n 'not yet metered or bounded per org (AGL-2810, AGL-2812). Images, ' +\n 'documents and stored videos are unaffected.',\n defaultEnabled: false,\n },\n // Delivery, not ingress: this flag decides whether a video already in the\n // library is COPIED to the configured delivery provider and served from it\n // through a short-lived signed redirect (AGL-2824). Per org, with no staff\n // preview on the server — a copy is customer data at the provider, so a\n // grant goes to the org through its override. Off, or with no provider\n // configured, every video serves from this platform exactly as before.\n // `media-delivery.ts` in `tenant-data-admin` holds the gate.\n {\n key: 'release_video_delivery',\n label: 'Video delivery',\n description:\n 'Serves library video from a configured delivery provider through ' +\n 'short-lived signed redirects, and copies each video to it (AGL-2824). ' +\n 'OFF by default: video serves from this platform as before. Also ' +\n \"needs the provider's settings.\",\n defaultEnabled: false,\n },\n // The generative doors share one runtime with Assist (AGL-2903) but not\n // its flag: `release_assist` answers questions, this builds things, and\n // the two are sold, metered and disclosed separately. The server-side\n // gate lives in `aiGateLadder` (404 when off, staff preview).\n // Labeled for what it switches — the generative doors — and not with the\n // add-on's own name, which is sold and live whether or not the doors are\n // released: the docs name the add-on, and a flag wearing the same words\n // reads as documented when it is not.\n {\n key: 'release_ai_generative',\n label: `${PLATFORM_BRAND_NAME} AI generation`,\n description:\n 'Generative building and automation behind the AI add-on ' +\n '(AGL-2903): sections, pages and workflows written by a model from ' +\n 'a brief, on the shared Anthropic runtime. ON for every workspace ' +\n 'since 2026-09-29; every generative route answers 404 while it is ' +\n 'off. On, it sends customer briefs and site content to Anthropic ' +\n 'on the same ANTHROPIC_API_KEY and under the same subprocessor ' +\n 'disclosure as Assist (AGL-1909).',\n defaultEnabled: true,\n },\n // What CRM assistance hands the model provider, not whether it runs:\n // `release_ai_generative` opens the doors, this widens what a record sends\n // through them. Per org, with no staff preview — the data is the customer's,\n // and a staff session reading a customer's record must send what the\n // published pages promise. The CRM's record-facts readers hold the gate.\n {\n key: 'release_crm_assist_whole_record',\n label: 'CRM assistance: whole record',\n description:\n 'What AI assistance in the CRM (a record summary, next step or ' +\n 'email draft) sends the model provider (AGL-3520). OFF: the ' +\n 'disclosed fields, with addresses and numbers in typed text ' +\n 'replaced. ON: the whole record, contact details and custom fields ' +\n 'included. ON since 2026-10-05, after Privacy Policy section 2 and ' +\n 'the Subprocessors row were republished; see ' +\n 'docs/drafts/agl-3520-crm-assistance-whole-record-disclosure.md.',\n defaultEnabled: true,\n },\n]\n\n/**\n * The registry: one entry per gated feature, the plugins' first. Adding a\n * platform flag = add it above, seed it in the Remote Config template, and\n * (optionally) wrap the page in `<FeatureGate>` — the staff admin editor and\n * nav filtering pick it up from this list.\n */\nexport const RELEASE_FLAGS: readonly ReleaseFlagDefinition[] = [\n ...PLUGIN_RELEASE_FLAGS,\n ...PLATFORM_RELEASE_FLAGS,\n]\n\nexport const RELEASE_FLAG_KEYS = RELEASE_FLAGS.map(\n (definition) => definition.key,\n) as readonly ReleaseFlagKey[]\n\nexport function isReleaseFlagKey(value: string): value is ReleaseFlagKey {\n return (RELEASE_FLAG_KEYS as readonly string[]).includes(value)\n}\n\nexport function getReleaseFlagDefinition(\n key: ReleaseFlagKey,\n): ReleaseFlagDefinition {\n const definition = RELEASE_FLAGS.find((entry) => entry.key === key)\n if (!definition) throw new Error(`Unknown release flag: ${key}`)\n return definition\n}\n\n/**\n * The JSON payload stored in each Remote Config parameter. `enabled: true`\n * turns the feature on for everyone; `enabled: false` with a positive\n * `rolloutPercent` enables it for that percentage of subjects (stable\n * per-tenant bucketing, GrowthBook-style); `plans` narrows either of those\n * to a set of tiers (AGL-2486).\n */\nexport interface ReleaseFlagValue {\n enabled: boolean\n /** 0–100; only consulted while `enabled` is false. */\n rolloutPercent?: number\n /**\n * Plan/tier targeting (AGL-2486). The tiers this flag is being rolled out\n * to, or ABSENT for \"every tier\".\n *\n * ABSENT AND EMPTY BOTH MEAN EVERY TIER, and that is load-bearing: every\n * flag stored before this field existed parses to `undefined` here, and an\n * operator who unticks every box has plainly not asked for a flag that\n * reaches nobody. Reading an empty list as \"no tiers\" would dark-launch\n * nothing while the console still said the flag was on — the inverted\n * reading of this field is the whole hazard, so it is closed in the parser\n * rather than left to each call site.\n */\n plans?: OrgPlan[]\n /** Free-form staff note (\"waiting on AGL-199\", owner, etc.). */\n note?: string\n}\n\nconst clampPercent = (value: unknown): number => {\n const percent = typeof value === 'number' && Number.isFinite(value) ? value : 0\n return Math.min(100, Math.max(0, Math.round(percent)))\n}\n\n/**\n * Every plan a flag may target, cheapest first — `SELF_SERVE_PLANS` then\n * `enterprise`, the same ladder `planGrantingFeature` walks and the same\n * order the plan grid renders. Derived, never re-typed: pricing v3 inserted\n * `scale` mid-ladder and added `agency`, and a second hand-written tier list\n * here would have missed both.\n */\nexport const RELEASE_FLAG_PLAN_LADDER: readonly OrgPlan[] = [\n ...SELF_SERVE_PLANS,\n 'enterprise' as OrgPlan,\n]\n\nconst isOrgPlan = (value: unknown): value is OrgPlan =>\n typeof value === 'string' &&\n Object.prototype.hasOwnProperty.call(PLAN_LABELS, value)\n\n/**\n * The tiers on the ladder at or above `plan` — what the console's \"Pro and\n * above\" shortcut expands to before it is STORED as an explicit list.\n *\n * Deliberately a UI convenience and not a stored \"minimum plan\" mode: a\n * stored threshold would silently re-aim every live flag the day a tier is\n * inserted into the middle of the ladder (pricing v3 did exactly that with\n * `scale`). An explicit list means the audience of a published flag only\n * ever changes because a human changed it.\n */\nexport function releaseFlagPlansAtOrAbove(plan: OrgPlan): OrgPlan[] {\n const index = RELEASE_FLAG_PLAN_LADDER.indexOf(plan)\n return index < 0 ? [] : RELEASE_FLAG_PLAN_LADDER.slice(index)\n}\n\n/**\n * Sanitises whatever is stored at `plans`, in ladder order.\n *\n * Returns `undefined` — not `[]` — for \"no targeting declared\", so the\n * absence survives a round trip through the staff editor and cannot be\n * written back as a list that means something else. Unknown tier names are\n * dropped for the same reason `parseOrgReleaseFlagOverrides` drops unknown\n * keys: a renamed or retired tier must never gate anything. A list that\n * names ONLY unknown tiers collapses to `undefined` (every tier) rather than\n * to an empty list, because a typo must inherit, never silently target.\n */\nexport function parseReleaseFlagPlans(raw: unknown): OrgPlan[] | undefined {\n if (!Array.isArray(raw)) return undefined\n const named = new Set(raw.filter(isOrgPlan))\n if (named.size === 0) return undefined\n return RELEASE_FLAG_PLAN_LADDER.filter((plan) => named.has(plan))\n}\n\n/**\n * Parses a Remote Config parameter string into a `ReleaseFlagValue`.\n * Tolerant by design — the template is hand-editable in the Firebase\n * console, so plain \"true\"/\"false\" strings and malformed JSON must not\n * crash gating: anything unreadable falls back to the registry default.\n */\nexport function parseReleaseFlagValue(\n raw: string | null | undefined,\n fallbackEnabled: boolean,\n): ReleaseFlagValue {\n const text = raw?.trim()\n if (!text) return { enabled: fallbackEnabled }\n if (text === 'true') return { enabled: true }\n if (text === 'false') return { enabled: false }\n try {\n const parsed = JSON.parse(text)\n if (typeof parsed === 'boolean') return { enabled: parsed }\n if (parsed && typeof parsed === 'object') {\n const plans = parseReleaseFlagPlans(parsed.plans)\n return {\n enabled: parsed.enabled === true,\n rolloutPercent: clampPercent(parsed.rolloutPercent),\n // Spread, so a flag stored before AGL-2486 has NO `plans` key at all\n // rather than an explicit `undefined`. `JSON.stringify` drops both,\n // but the two are not the same to `'plans' in value`, which is what\n // the staff PUT uses to tell \"the operator cleared the targeting\"\n // apart from \"this client never sent the field\".\n ...(plans ? { plans } : {}),\n note: typeof parsed.note === 'string' ? parsed.note : undefined,\n }\n }\n } catch {\n // fall through to the registry default\n }\n return { enabled: fallbackEnabled }\n}\n\n/**\n * The longest parameter description Remote Config will publish (AGL-3048).\n *\n * One description past it refuses the WHOLE publish with\n * `DESCRIPTION_EXCEEDS_MAXIMUM_SIZE`, which the staff flags route answered as\n * a 500. Registry descriptions are written for the staff flags page, and\n * several run far past the limit, so what the page shows and what a\n * parameter carries cannot always be the same text.\n *\n * Counted in UTF-8 BYTES. Remote Config states the limit in characters\n * without saying how it counts them, and a UTF-8 byte count is never smaller\n * than the code-point or UTF-16 count, so text within 256 bytes is within the\n * limit on every reading. The cost is a few characters of headroom on text\n * with typographic punctuation.\n */\nexport const REMOTE_CONFIG_DESCRIPTION_MAX_BYTES = 256\n\nfunction utf8ByteLength(text: string): number {\n let bytes = 0\n for (const character of text) {\n const codePoint = character.codePointAt(0) ?? 0\n bytes +=\n codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : codePoint < 0x10000 ? 3 : 4\n }\n return bytes\n}\n\n/** Whether Remote Config accepts `text` as a parameter description. */\nexport function fitsRemoteConfigDescription(text: string): boolean {\n return utf8ByteLength(text) <= REMOTE_CONFIG_DESCRIPTION_MAX_BYTES\n}\n\nconst DESCRIPTION_ELLIPSIS = '…'\n\n/**\n * `text` cut to a length Remote Config accepts, at a word boundary, with an\n * ellipsis marking the cut. Text that already fits comes back unchanged.\n *\n * The cut takes whole code points, so a multi-byte character is never split.\n * It backs up to the last space when it lands inside a word (only a single\n * word longer than the whole budget is cut hard), and punctuation left\n * dangling before the ellipsis is dropped.\n */\nexport function clampRemoteConfigDescription(text: string): string {\n if (fitsRemoteConfigDescription(text)) return text\n const budget =\n REMOTE_CONFIG_DESCRIPTION_MAX_BYTES - utf8ByteLength(DESCRIPTION_ELLIPSIS)\n let kept = ''\n let bytes = 0\n for (const character of text) {\n const size = utf8ByteLength(character)\n if (bytes + size > budget) break\n kept += character\n bytes += size\n }\n const next = text.charAt(kept.length)\n if (next && !/\\s/.test(next)) {\n const lastSpace = kept.search(/\\s\\S*$/)\n if (lastSpace > 0) kept = kept.slice(0, lastSpace)\n }\n return `${kept.replace(/[\\s.,;:(–—-]+$/, '')}${DESCRIPTION_ELLIPSIS}`\n}\n\n/**\n * The description to publish with a release flag's Remote Config parameter\n * (AGL-3048). Always one Remote Config accepts:\n *\n * 1. The registry description, when it fits, so the flags page and the\n * parameter say the same thing.\n * 2. Otherwise the LIVE parameter's description, when it has one that fits.\n * Remote Config has already accepted exactly that text, and someone chose\n * it for the Firebase console. The first 250 characters of a longer\n * paragraph would drop whatever the paragraph says last: for\n * `release_assist`, the `/legal/subprocessors` precondition that its\n * AGL-1909 suite wants in front of whoever reads the flag there. The full\n * registry text stays on the staff flags page either way.\n * 3. Otherwise, as on a first publish with nothing live to keep, the\n * registry description clamped at a word boundary.\n */\nexport function releaseFlagParameterDescription(\n registryDescription: string,\n liveDescription: string | null | undefined,\n): string {\n if (fitsRemoteConfigDescription(registryDescription)) {\n return registryDescription\n }\n if (\n typeof liveDescription === 'string' &&\n liveDescription.trim().length > 0 &&\n fitsRemoteConfigDescription(liveDescription)\n ) {\n return liveDescription\n }\n return clampRemoteConfigDescription(registryDescription)\n}\n\n/**\n * FNV-1a 32-bit hash → 0–99 bucket. Deterministic so a subject keeps the\n * same rollout verdict across sessions and surfaces, and seeded with the\n * flag key so a subject doesn't land in the same bucket for every flag.\n */\nexport function releaseFlagBucket(flagKey: string, subjectId: string): number {\n let hash = 0x811c9dc5\n const seed = `${flagKey}:${subjectId}`\n for (let index = 0; index < seed.length; index += 1) {\n hash ^= seed.charCodeAt(index)\n hash = Math.imul(hash, 0x01000193)\n }\n return (hash >>> 0) % 100\n}\n\n/**\n * The gating verdict for one subject.\n *\n * `subjectId` is the ORG ID, or nothing. A rollout is a cohort of\n * workspaces, so a whole workspace has to land on one side of the\n * percentage: every surface that asks about an org must ask with the same\n * string, or they answer differently about the same customer.\n *\n * This docstring used to sanction falling back to the uid, and the server\n * gates fell back to a hostId. Since a hostId, a uid and an orgId hash to\n * three different buckets, a mid-rollout flag could be on in the console\n * and off on the published site for one workspace — stable per subject, so\n * it never flickered, it just stayed wrong (AGL-1656).\n *\n * An empty subject only passes fully-enabled flags. That is the deliberate\n * cost: a request with no resolvable org never joins a partial rollout,\n * which is the conservative answer rather than a confidently wrong one.\n */\nexport function isReleaseFlagOn(\n flagKey: ReleaseFlagKey,\n value: ReleaseFlagValue,\n subjectId: string | null | undefined,\n /**\n * The subject org's plan, when the caller knows it (AGL-2486). Optional so\n * every pre-existing call site compiles and answers unchanged: a flag that\n * declares no tier targeting never reads this.\n */\n plan?: OrgPlan | null,\n): boolean {\n if (!releaseFlagTargetsPlan(value, plan)) return false\n if (value.enabled) return true\n const percent = clampPercent(value.rolloutPercent)\n if (percent <= 0 || !subjectId) return false\n if (percent >= 100) return true\n return releaseFlagBucket(flagKey, subjectId) < percent\n}\n\n/**\n * Does this flag's tier targeting admit `plan`? (AGL-2486)\n *\n * THE SEMANTICS, because an operator who cannot predict the audience will\n * not stage a rollout at all:\n *\n * - The tier list is a FILTER, applied before and independently of the\n * percentage. The percentage then picks a cohort WITHIN the admitted\n * tiers. \"Pro and above at 50%\" is therefore both \"half of the Pro+\n * workspaces\" and \"the global 50% cohort, restricted to Pro+\" — the two\n * readings name the same set, and they do so because\n * {@link releaseFlagBucket} hashes `flagKey:orgId` and NOTHING ELSE.\n * - Which is also why the bucket is stable. Adding, removing or reordering\n * tiers cannot reshuffle the bucket, so an org already inside a 50%\n * rollout keeps the feature when an unrelated tier joins the list. Had\n * the plan been mixed into the hash — the obvious way to write this — a\n * tier edit would have re-drawn the cohort under every customer already\n * in it, and a plan change would have re-rolled the dice for one.\n * - The filter binds the FULLY-ENABLED path too, not just the rollout.\n * \"On, Enterprise + Agency\" is how a launch to the top of the ladder is\n * expressed, and it is what was actually asked for. Untargeted flags are\n * unaffected: no list means every tier, so `enabled` still means everyone.\n * - An UNKNOWN plan fails a declared list. Same conservatism as a missing\n * subject on a percentage rollout: a caller that cannot say which\n * workspace it is asking about gets the safe answer rather than a\n * confidently wrong one. A per-org staff override still wins over all of\n * it — see {@link isReleaseFlagOnForOrg} — so a targeted flag can still be\n * handed to one org off-ladder.\n */\nexport function releaseFlagTargetsPlan(\n value: ReleaseFlagValue,\n plan: OrgPlan | null | undefined,\n): boolean {\n const plans = value.plans\n // `!plans` would be the idiom here, but `strictNullChecks` is off repo-wide\n // and an empty array is truthy — both \"absent\" and \"empty\" have to be named\n // for the every-tier reading to actually hold.\n if (plans == null || plans.length === 0) return true\n if (!plan) return false\n return plans.includes(plan)\n}\n\n/**\n * Per-org release-flag overrides (AGL-1635), stored on the org doc at\n * `releaseFlags`. A present key is a staff DECISION about one organization\n * and wins over both the Remote Config value and the rollout bucket; an\n * absent key inherits.\n *\n * A separate field from `entitlements.features` on purpose: those ask\n * whether the org's PLAN includes a feature, these ask whether a\n * not-yet-released feature is switched on for this one customer. Folding\n * them together would make \"granted by the deal\" and \"previewing an\n * unreleased build\" indistinguishable at the point a support question is\n * asked.\n *\n * Why the org doc and not a Remote Config condition: RC conditions are\n * template-global and would need one published condition per org, with a\n * template publish (a manual, separate deploy) for every grant.\n */\nexport type OrgReleaseFlagOverrides = Partial<Record<ReleaseFlagKey, boolean>>\n\n/**\n * Sanitises whatever is actually stored at `org.releaseFlags`.\n *\n * Tolerant for the same reason `parseReleaseFlagValue` is: this map is\n * hand-editable in the Firebase console and survives registry renames, so a\n * retired flag key or a non-boolean must be dropped rather than allowed to\n * gate anything. Unknown keys are discarded — a stale key can never grant a\n * flag that no longer exists, and a typo silently inherits instead of\n * silently forcing.\n */\nexport function parseOrgReleaseFlagOverrides(\n raw: unknown,\n): OrgReleaseFlagOverrides {\n if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}\n const overrides: OrgReleaseFlagOverrides = {}\n for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {\n if (typeof value === 'boolean' && isReleaseFlagKey(key)) {\n overrides[key] = value\n }\n }\n return overrides\n}\n\n/**\n * The gating verdict with a per-org override applied on top of\n * `isReleaseFlagOn`.\n *\n * The override is checked FIRST and short-circuits, so a forced-off flag\n * stays off for an org even while the flag is globally enabled — the\n * per-org kill switch is half the point, not just the per-org grant. Every\n * release-flag gate resolves through here so the console, the tenant\n * runtime and the API dispatchers cannot disagree about one org.\n */\nexport function isReleaseFlagOnForOrg(\n flagKey: ReleaseFlagKey,\n value: ReleaseFlagValue,\n subjectId: string | null | undefined,\n overrides: OrgReleaseFlagOverrides | null | undefined,\n /**\n * The org's plan, for tier targeting (AGL-2486). Checked only AFTER the\n * override: a staff grant is a decision about one named customer and\n * outranks the tier filter exactly as it already outranks the rollout\n * bucket. That ordering is what lets a Free org preview a\n * Business-targeted flag without widening the flag for every Free org.\n *\n * REQUIRED, and `null` has to be typed out. It was optional for one\n * release and that cost a silent revenue bug: `report-usage` held the org\n * document, dropped the argument, and every `plans`-declaring flag read\n * OFF — so the contacts overage went uninvoiced for exactly the orgs\n * entitled to reach the feature, with nothing thrown and nothing logged.\n * An unknown tier still refuses, which is the correct conservatism; what\n * cannot be allowed is a caller MANUFACTURING an unknown out of a tier it\n * already has in hand. Spelling `null` makes that a statement rather than\n * an omission, and makes the next call site that forgets fail to compile\n * instead of failing to bill. `getOrgReleaseFlagTargeting` returns the\n * plan beside the overrides from ONE document read precisely so that\n * passing it costs nothing.\n */\n plan: OrgPlan | null,\n): boolean {\n const override = overrides?.[flagKey]\n if (typeof override === 'boolean') return override\n return isReleaseFlagOn(flagKey, value, subjectId, plan)\n}\n"],"names":["PLAN_LABELS","SELF_SERVE_PLANS","PLATFORM_BRAND_NAME","PLUGIN_RELEASE_FLAGS","PLATFORM_RELEASE_FLAGS","key","label","description","defaultEnabled","RELEASE_FLAGS","RELEASE_FLAG_KEYS","map","definition","isReleaseFlagKey","value","includes","getReleaseFlagDefinition","find","entry","Error","clampPercent","percent","Number","isFinite","Math","min","max","round","RELEASE_FLAG_PLAN_LADDER","isOrgPlan","Object","prototype","hasOwnProperty","call","releaseFlagPlansAtOrAbove","plan","index","indexOf","slice","parseReleaseFlagPlans","raw","Array","isArray","undefined","named","Set","filter","size","has","parseReleaseFlagValue","fallbackEnabled","text","trim","enabled","parsed","JSON","parse","plans","rolloutPercent","note","REMOTE_CONFIG_DESCRIPTION_MAX_BYTES","utf8ByteLength","bytes","character","codePoint","codePointAt","fitsRemoteConfigDescription","DESCRIPTION_ELLIPSIS","clampRemoteConfigDescription","budget","kept","next","charAt","length","test","lastSpace","search","replace","releaseFlagParameterDescription","registryDescription","liveDescription","releaseFlagBucket","flagKey","subjectId","hash","seed","charCodeAt","imul","isReleaseFlagOn","releaseFlagTargetsPlan","parseOrgReleaseFlagOverrides","overrides","entries","isReleaseFlagOnForOrg","override"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/release-flags.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Release flags (AGL-227): platform-level \"is this feature launched\"\n * gating backed by Firebase Remote Config. A separate axis from plan\n * entitlements (`plan-entitlements.ts`) — entitlements ask whether the\n * tenant's PLAN includes a feature, release flags ask whether the feature\n * is released to the public at all (or partially, via percentage rollout).\n * Staff bypass release flags everywhere but see a flagged warning.\n */\n\n/** Remote Config parameter keys for release-gated features. */\nimport type { OrgPlan } from '../foundation'\nimport { PLAN_LABELS, SELF_SERVE_PLANS } from './plan-entitlements'\nimport { PLATFORM_BRAND_NAME } from './platform-brand'\nimport {\n PLUGIN_RELEASE_FLAGS,\n type PluginReleaseFlagKey,\n} from './plugin-release-flags.generated'\n\n/**\n * Every release flag: the ones plugins declare (compiled from\n * plugins.config.json, AGL-3080) and the platform's own below.\n */\nexport type ReleaseFlagKey =\n | PluginReleaseFlagKey\n | 'release_addon_store'\n | 'release_native_checkout'\n | 'release_edit_bar'\n | 'release_assist'\n | 'release_video_uploads'\n | 'release_video_delivery'\n | 'release_ai_generative'\n | 'release_crm_assist_whole_record'\n | 'release_ad_audience_sync'\n\nexport interface ReleaseFlagDefinition {\n key: ReleaseFlagKey\n label: string\n description: string\n /**\n * Fallback verdict when Remote Config is unreachable (offline, blocked,\n * first paint before activate). MUST match the seeded value in\n * cloud/firebase-remoteconfig.template.json so environments without a\n * published template behave like the template intends.\n */\n defaultEnabled: boolean\n /** Host dashboard tab id (host-nav-tabs.ts) this flag hides, if any. */\n navTabId?: string\n}\n\n/** A release flag a plugin declares, as the generator compiles it. */\nexport type PluginReleaseFlagDefinition = ReleaseFlagDefinition & {\n key: PluginReleaseFlagKey\n}\n\n/**\n * The platform's own release flags: features no plugin owns.\n *\n * Every first-party plugin is release-flagged too (AGL-422) — the flag feeds\n * the plugin LOADER (console, published sites, API dispatch), not just nav\n * visibility, so staff can kill-switch a whole plugin platform-wide from the\n * Feature Flags page. Those flags are the plugins' own: each is defined by\n * its catalog row's `releaseFlagDefinition` in plugins.config.json and\n * compiled into `PLUGIN_RELEASE_FLAGS`, so adding a plugin edits nothing\n * here.\n */\nconst PLATFORM_RELEASE_FLAGS: readonly ReleaseFlagDefinition[] = [\n {\n key: 'release_addon_store',\n label: 'Add-on store',\n description:\n 'Self-serve add-on purchases on the Billing page: seats, datasets, ' +\n 'extra sites, POS registers, Event Calendar (AGL-524..531).',\n defaultEnabled: true,\n },\n // Released to every workspace 2026-10-06 (AGL-3606). Storefront-only since\n // the console's plan checkout stopped rendering Stripe Checkout at all. Still\n // the surface's kill switch, globally or per org: off, both storefront\n // checkout routes return the hosted redirect again.\n {\n key: 'release_native_checkout',\n label: 'In-page checkout',\n description:\n 'Storefront shoppers pay on the merchant\\u2019s own site: the Payment ' +\n 'Element, email, shipping address and method, and a live total with ' +\n 'tax open in place under the Buy or Checkout button, styled with the ' +\n 'site theme, instead of a redirect to checkout.stripe.com (AGL-1944). ' +\n 'Released to every workspace 2026-10-06 (AGL-3606). The webhook is ' +\n 'still the only thing that fulfils. Also gated on ' +\n 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, so a deployment without one keeps ' +\n 'the redirect; turning this off returns every storefront to the redirect.',\n defaultEnabled: true,\n },\n // Released to every site 2026-09-16 (AGL-3041). Still the whole surface's\n // kill switch, globally or for one org by override: off, the tenant slot\n // renders nothing, both token mints refuse, and `/api/edit-context` turns\n // away every token already issued.\n {\n key: 'release_edit_bar',\n label: 'Site admin bar',\n description:\n 'Admin bar on published sites: a signed-in editor jumps from a live ' +\n 'page straight into the Besigner to edit it. Released ' +\n '2026-09-16 (AGL-3041) for every site; turning it off hides the bar ' +\n 'and revokes every outstanding edit token at the verify site.',\n defaultEnabled: true,\n },\n {\n key: 'release_assist',\n label: `${PLATFORM_BRAND_NAME} Assist`,\n description:\n 'The in-console AI chat helper on every page (AGL-1860): docs-' +\n 'grounded answers with deep links, page-context guidance on Pro+. ' +\n 'ON in production, and gated on TWO published legal artifacts ' +\n '(AGL-1909), neither of which is a repo file — both are live besigner ' +\n 'pages, so publication is what satisfies them: (1) the privacy-policy ' +\n 'disclosure for stored Q&A, because the data loop records every ' +\n 'exchange org-scoped under orgs/{orgId}/assistExchanges; and (2) the ' +\n 'Anthropic row on /legal/subprocessors, which was deliberately ' +\n 'REMOVED on 2026-08-13 (subprocessorsV2a-20260813) on the premise ' +\n 'that no production key existed — so the page is not merely ' +\n 'incomplete without it, it is affirmatively wrong. NOTE the real ' +\n 'trigger is ANTHROPIC_API_KEY, not this flag: /api/ai/assist (the ' +\n 'besigner copy assistant, AGL-89/130/169) carries no release flag at ' +\n 'all and sends customer site content to Anthropic on the key plus a ' +\n 'Pro entitlement alone. Setting the key in production therefore makes ' +\n 'Anthropic a subprocessor whether or not this flag is ever flipped.',\n defaultEnabled: true,\n },\n // Ingress only, and with no staff preview on the server: a video a staff\n // session uploads into a customer's library serves on that customer's pages\n // like any other, so a grant goes to the org through its override.\n // `apps/console/utils/server/video-uploads.ts` holds the gate.\n {\n key: 'release_video_uploads',\n label: 'Video uploads',\n description:\n 'New video in the media library, on every upload path (AGL-2830). ' +\n 'Off: refused with 403 video_uploads_paused, as DAM video delivery is ' +\n 'not yet metered or bounded per org (AGL-2810, AGL-2812). Images, ' +\n 'documents and stored videos are unaffected.',\n defaultEnabled: false,\n },\n // Delivery, not ingress: this flag decides whether a video already in the\n // library is COPIED to the configured delivery provider and served from it\n // through a short-lived signed redirect (AGL-2824). Per org, with no staff\n // preview on the server — a copy is customer data at the provider, so a\n // grant goes to the org through its override. Off, or with no provider\n // configured, every video serves from this platform exactly as before.\n // `media-delivery.ts` in `tenant-data-admin` holds the gate.\n {\n key: 'release_video_delivery',\n label: 'Video delivery',\n description:\n 'Serves library video from a configured delivery provider through ' +\n 'short-lived signed redirects, and copies each video to it (AGL-2824). ' +\n 'OFF by default: video serves from this platform as before. Also ' +\n \"needs the provider's settings.\",\n defaultEnabled: false,\n },\n // The generative doors share one runtime with Assist (AGL-2903) but not\n // its flag: `release_assist` answers questions, this builds things, and\n // the two are sold, metered and disclosed separately. The server-side\n // gate lives in `aiGateLadder` (404 when off, staff preview).\n // Labeled for what it switches — the generative doors — and not with the\n // add-on's own name, which is sold and live whether or not the doors are\n // released: the docs name the add-on, and a flag wearing the same words\n // reads as documented when it is not.\n {\n key: 'release_ai_generative',\n label: `${PLATFORM_BRAND_NAME} AI generation`,\n description:\n 'Generative building and automation behind the AI add-on ' +\n '(AGL-2903): sections, pages and workflows written by a model from ' +\n 'a brief, on the shared Anthropic runtime. ON for every workspace ' +\n 'since 2026-09-29; every generative route answers 404 while it is ' +\n 'off. On, it sends customer briefs and site content to Anthropic ' +\n 'on the same ANTHROPIC_API_KEY and under the same subprocessor ' +\n 'disclosure as Assist (AGL-1909).',\n defaultEnabled: true,\n },\n // What CRM assistance hands the model provider, not whether it runs:\n // `release_ai_generative` opens the doors, this widens what a record sends\n // through them. Per org, with no staff preview — the data is the customer's,\n // and a staff session reading a customer's record must send what the\n // published pages promise. The CRM's record-facts readers hold the gate.\n {\n key: 'release_crm_assist_whole_record',\n label: 'CRM assistance: whole record',\n description:\n 'What AI assistance in the CRM (a record summary, next step or ' +\n 'email draft) sends the model provider (AGL-3520). OFF: the ' +\n 'disclosed fields, with addresses and numbers in typed text ' +\n 'replaced. ON: the whole record, contact details and custom fields ' +\n 'included. ON since 2026-10-05, after Privacy Policy section 2 and ' +\n 'the Subprocessors row were republished; see ' +\n 'docs/drafts/agl-3520-crm-assistance-whole-record-disclosure.md.',\n defaultEnabled: true,\n },\n // Platform-wide, never per org: the nightly job reads only the platform\n // value, because it is the operator's own marketing and no workspace's\n // feature. `apps/console/app/api/admin/ad-audience-sync/route.ts` holds the\n // gate; a dry run reports the counts whichever way it is set.\n {\n key: 'release_ad_audience_sync',\n label: 'Account-holder ad audiences',\n description:\n 'Nightly sync of United States account holders, as SHA-256 hashed ' +\n 'email addresses, to the operator\\u2019s own Meta, Google and LinkedIn ' +\n 'customer-list audiences (AGL-3780). OFF by default: nothing is sent ' +\n 'until the Privacy Policy that discloses it is published.',\n defaultEnabled: false,\n },\n]\n\n/**\n * The registry: one entry per gated feature, the plugins' first. Adding a\n * platform flag = add it above, seed it in the Remote Config template, and\n * (optionally) wrap the page in `<FeatureGate>` — the staff admin editor and\n * nav filtering pick it up from this list.\n */\nexport const RELEASE_FLAGS: readonly ReleaseFlagDefinition[] = [\n ...PLUGIN_RELEASE_FLAGS,\n ...PLATFORM_RELEASE_FLAGS,\n]\n\nexport const RELEASE_FLAG_KEYS = RELEASE_FLAGS.map(\n (definition) => definition.key,\n) as readonly ReleaseFlagKey[]\n\nexport function isReleaseFlagKey(value: string): value is ReleaseFlagKey {\n return (RELEASE_FLAG_KEYS as readonly string[]).includes(value)\n}\n\nexport function getReleaseFlagDefinition(\n key: ReleaseFlagKey,\n): ReleaseFlagDefinition {\n const definition = RELEASE_FLAGS.find((entry) => entry.key === key)\n if (!definition) throw new Error(`Unknown release flag: ${key}`)\n return definition\n}\n\n/**\n * The JSON payload stored in each Remote Config parameter. `enabled: true`\n * turns the feature on for everyone; `enabled: false` with a positive\n * `rolloutPercent` enables it for that percentage of subjects (stable\n * per-tenant bucketing, GrowthBook-style); `plans` narrows either of those\n * to a set of tiers (AGL-2486).\n */\nexport interface ReleaseFlagValue {\n enabled: boolean\n /** 0–100; only consulted while `enabled` is false. */\n rolloutPercent?: number\n /**\n * Plan/tier targeting (AGL-2486). The tiers this flag is being rolled out\n * to, or ABSENT for \"every tier\".\n *\n * ABSENT AND EMPTY BOTH MEAN EVERY TIER, and that is load-bearing: every\n * flag stored before this field existed parses to `undefined` here, and an\n * operator who unticks every box has plainly not asked for a flag that\n * reaches nobody. Reading an empty list as \"no tiers\" would dark-launch\n * nothing while the console still said the flag was on — the inverted\n * reading of this field is the whole hazard, so it is closed in the parser\n * rather than left to each call site.\n */\n plans?: OrgPlan[]\n /** Free-form staff note (\"waiting on AGL-199\", owner, etc.). */\n note?: string\n}\n\nconst clampPercent = (value: unknown): number => {\n const percent = typeof value === 'number' && Number.isFinite(value) ? value : 0\n return Math.min(100, Math.max(0, Math.round(percent)))\n}\n\n/**\n * Every plan a flag may target, cheapest first — `SELF_SERVE_PLANS` then\n * `enterprise`, the same ladder `planGrantingFeature` walks and the same\n * order the plan grid renders. Derived, never re-typed: pricing v3 inserted\n * `scale` mid-ladder and added `agency`, and a second hand-written tier list\n * here would have missed both.\n */\nexport const RELEASE_FLAG_PLAN_LADDER: readonly OrgPlan[] = [\n ...SELF_SERVE_PLANS,\n 'enterprise' as OrgPlan,\n]\n\nconst isOrgPlan = (value: unknown): value is OrgPlan =>\n typeof value === 'string' &&\n Object.prototype.hasOwnProperty.call(PLAN_LABELS, value)\n\n/**\n * The tiers on the ladder at or above `plan` — what the console's \"Pro and\n * above\" shortcut expands to before it is STORED as an explicit list.\n *\n * Deliberately a UI convenience and not a stored \"minimum plan\" mode: a\n * stored threshold would silently re-aim every live flag the day a tier is\n * inserted into the middle of the ladder (pricing v3 did exactly that with\n * `scale`). An explicit list means the audience of a published flag only\n * ever changes because a human changed it.\n */\nexport function releaseFlagPlansAtOrAbove(plan: OrgPlan): OrgPlan[] {\n const index = RELEASE_FLAG_PLAN_LADDER.indexOf(plan)\n return index < 0 ? [] : RELEASE_FLAG_PLAN_LADDER.slice(index)\n}\n\n/**\n * Sanitises whatever is stored at `plans`, in ladder order.\n *\n * Returns `undefined` — not `[]` — for \"no targeting declared\", so the\n * absence survives a round trip through the staff editor and cannot be\n * written back as a list that means something else. Unknown tier names are\n * dropped for the same reason `parseOrgReleaseFlagOverrides` drops unknown\n * keys: a renamed or retired tier must never gate anything. A list that\n * names ONLY unknown tiers collapses to `undefined` (every tier) rather than\n * to an empty list, because a typo must inherit, never silently target.\n */\nexport function parseReleaseFlagPlans(raw: unknown): OrgPlan[] | undefined {\n if (!Array.isArray(raw)) return undefined\n const named = new Set(raw.filter(isOrgPlan))\n if (named.size === 0) return undefined\n return RELEASE_FLAG_PLAN_LADDER.filter((plan) => named.has(plan))\n}\n\n/**\n * Parses a Remote Config parameter string into a `ReleaseFlagValue`.\n * Tolerant by design — the template is hand-editable in the Firebase\n * console, so plain \"true\"/\"false\" strings and malformed JSON must not\n * crash gating: anything unreadable falls back to the registry default.\n */\nexport function parseReleaseFlagValue(\n raw: string | null | undefined,\n fallbackEnabled: boolean,\n): ReleaseFlagValue {\n const text = raw?.trim()\n if (!text) return { enabled: fallbackEnabled }\n if (text === 'true') return { enabled: true }\n if (text === 'false') return { enabled: false }\n try {\n const parsed = JSON.parse(text)\n if (typeof parsed === 'boolean') return { enabled: parsed }\n if (parsed && typeof parsed === 'object') {\n const plans = parseReleaseFlagPlans(parsed.plans)\n return {\n enabled: parsed.enabled === true,\n rolloutPercent: clampPercent(parsed.rolloutPercent),\n // Spread, so a flag stored before AGL-2486 has NO `plans` key at all\n // rather than an explicit `undefined`. `JSON.stringify` drops both,\n // but the two are not the same to `'plans' in value`, which is what\n // the staff PUT uses to tell \"the operator cleared the targeting\"\n // apart from \"this client never sent the field\".\n ...(plans ? { plans } : {}),\n note: typeof parsed.note === 'string' ? parsed.note : undefined,\n }\n }\n } catch {\n // fall through to the registry default\n }\n return { enabled: fallbackEnabled }\n}\n\n/**\n * The longest parameter description Remote Config will publish (AGL-3048).\n *\n * One description past it refuses the WHOLE publish with\n * `DESCRIPTION_EXCEEDS_MAXIMUM_SIZE`, which the staff flags route answered as\n * a 500. Registry descriptions are written for the staff flags page, and\n * several run far past the limit, so what the page shows and what a\n * parameter carries cannot always be the same text.\n *\n * Counted in UTF-8 BYTES. Remote Config states the limit in characters\n * without saying how it counts them, and a UTF-8 byte count is never smaller\n * than the code-point or UTF-16 count, so text within 256 bytes is within the\n * limit on every reading. The cost is a few characters of headroom on text\n * with typographic punctuation.\n */\nexport const REMOTE_CONFIG_DESCRIPTION_MAX_BYTES = 256\n\nfunction utf8ByteLength(text: string): number {\n let bytes = 0\n for (const character of text) {\n const codePoint = character.codePointAt(0) ?? 0\n bytes +=\n codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : codePoint < 0x10000 ? 3 : 4\n }\n return bytes\n}\n\n/** Whether Remote Config accepts `text` as a parameter description. */\nexport function fitsRemoteConfigDescription(text: string): boolean {\n return utf8ByteLength(text) <= REMOTE_CONFIG_DESCRIPTION_MAX_BYTES\n}\n\nconst DESCRIPTION_ELLIPSIS = '…'\n\n/**\n * `text` cut to a length Remote Config accepts, at a word boundary, with an\n * ellipsis marking the cut. Text that already fits comes back unchanged.\n *\n * The cut takes whole code points, so a multi-byte character is never split.\n * It backs up to the last space when it lands inside a word (only a single\n * word longer than the whole budget is cut hard), and punctuation left\n * dangling before the ellipsis is dropped.\n */\nexport function clampRemoteConfigDescription(text: string): string {\n if (fitsRemoteConfigDescription(text)) return text\n const budget =\n REMOTE_CONFIG_DESCRIPTION_MAX_BYTES - utf8ByteLength(DESCRIPTION_ELLIPSIS)\n let kept = ''\n let bytes = 0\n for (const character of text) {\n const size = utf8ByteLength(character)\n if (bytes + size > budget) break\n kept += character\n bytes += size\n }\n const next = text.charAt(kept.length)\n if (next && !/\\s/.test(next)) {\n const lastSpace = kept.search(/\\s\\S*$/)\n if (lastSpace > 0) kept = kept.slice(0, lastSpace)\n }\n return `${kept.replace(/[\\s.,;:(–—-]+$/, '')}${DESCRIPTION_ELLIPSIS}`\n}\n\n/**\n * The description to publish with a release flag's Remote Config parameter\n * (AGL-3048). Always one Remote Config accepts:\n *\n * 1. The registry description, when it fits, so the flags page and the\n * parameter say the same thing.\n * 2. Otherwise the LIVE parameter's description, when it has one that fits.\n * Remote Config has already accepted exactly that text, and someone chose\n * it for the Firebase console. The first 250 characters of a longer\n * paragraph would drop whatever the paragraph says last: for\n * `release_assist`, the `/legal/subprocessors` precondition that its\n * AGL-1909 suite wants in front of whoever reads the flag there. The full\n * registry text stays on the staff flags page either way.\n * 3. Otherwise, as on a first publish with nothing live to keep, the\n * registry description clamped at a word boundary.\n */\nexport function releaseFlagParameterDescription(\n registryDescription: string,\n liveDescription: string | null | undefined,\n): string {\n if (fitsRemoteConfigDescription(registryDescription)) {\n return registryDescription\n }\n if (\n typeof liveDescription === 'string' &&\n liveDescription.trim().length > 0 &&\n fitsRemoteConfigDescription(liveDescription)\n ) {\n return liveDescription\n }\n return clampRemoteConfigDescription(registryDescription)\n}\n\n/**\n * FNV-1a 32-bit hash → 0–99 bucket. Deterministic so a subject keeps the\n * same rollout verdict across sessions and surfaces, and seeded with the\n * flag key so a subject doesn't land in the same bucket for every flag.\n */\nexport function releaseFlagBucket(flagKey: string, subjectId: string): number {\n let hash = 0x811c9dc5\n const seed = `${flagKey}:${subjectId}`\n for (let index = 0; index < seed.length; index += 1) {\n hash ^= seed.charCodeAt(index)\n hash = Math.imul(hash, 0x01000193)\n }\n return (hash >>> 0) % 100\n}\n\n/**\n * The gating verdict for one subject.\n *\n * `subjectId` is the ORG ID, or nothing. A rollout is a cohort of\n * workspaces, so a whole workspace has to land on one side of the\n * percentage: every surface that asks about an org must ask with the same\n * string, or they answer differently about the same customer.\n *\n * This docstring used to sanction falling back to the uid, and the server\n * gates fell back to a hostId. Since a hostId, a uid and an orgId hash to\n * three different buckets, a mid-rollout flag could be on in the console\n * and off on the published site for one workspace — stable per subject, so\n * it never flickered, it just stayed wrong (AGL-1656).\n *\n * An empty subject only passes fully-enabled flags. That is the deliberate\n * cost: a request with no resolvable org never joins a partial rollout,\n * which is the conservative answer rather than a confidently wrong one.\n */\nexport function isReleaseFlagOn(\n flagKey: ReleaseFlagKey,\n value: ReleaseFlagValue,\n subjectId: string | null | undefined,\n /**\n * The subject org's plan, when the caller knows it (AGL-2486). Optional so\n * every pre-existing call site compiles and answers unchanged: a flag that\n * declares no tier targeting never reads this.\n */\n plan?: OrgPlan | null,\n): boolean {\n if (!releaseFlagTargetsPlan(value, plan)) return false\n if (value.enabled) return true\n const percent = clampPercent(value.rolloutPercent)\n if (percent <= 0 || !subjectId) return false\n if (percent >= 100) return true\n return releaseFlagBucket(flagKey, subjectId) < percent\n}\n\n/**\n * Does this flag's tier targeting admit `plan`? (AGL-2486)\n *\n * THE SEMANTICS, because an operator who cannot predict the audience will\n * not stage a rollout at all:\n *\n * - The tier list is a FILTER, applied before and independently of the\n * percentage. The percentage then picks a cohort WITHIN the admitted\n * tiers. \"Pro and above at 50%\" is therefore both \"half of the Pro+\n * workspaces\" and \"the global 50% cohort, restricted to Pro+\" — the two\n * readings name the same set, and they do so because\n * {@link releaseFlagBucket} hashes `flagKey:orgId` and NOTHING ELSE.\n * - Which is also why the bucket is stable. Adding, removing or reordering\n * tiers cannot reshuffle the bucket, so an org already inside a 50%\n * rollout keeps the feature when an unrelated tier joins the list. Had\n * the plan been mixed into the hash — the obvious way to write this — a\n * tier edit would have re-drawn the cohort under every customer already\n * in it, and a plan change would have re-rolled the dice for one.\n * - The filter binds the FULLY-ENABLED path too, not just the rollout.\n * \"On, Enterprise + Agency\" is how a launch to the top of the ladder is\n * expressed, and it is what was actually asked for. Untargeted flags are\n * unaffected: no list means every tier, so `enabled` still means everyone.\n * - An UNKNOWN plan fails a declared list. Same conservatism as a missing\n * subject on a percentage rollout: a caller that cannot say which\n * workspace it is asking about gets the safe answer rather than a\n * confidently wrong one. A per-org staff override still wins over all of\n * it — see {@link isReleaseFlagOnForOrg} — so a targeted flag can still be\n * handed to one org off-ladder.\n */\nexport function releaseFlagTargetsPlan(\n value: ReleaseFlagValue,\n plan: OrgPlan | null | undefined,\n): boolean {\n const plans = value.plans\n // `!plans` would be the idiom here, but `strictNullChecks` is off repo-wide\n // and an empty array is truthy — both \"absent\" and \"empty\" have to be named\n // for the every-tier reading to actually hold.\n if (plans == null || plans.length === 0) return true\n if (!plan) return false\n return plans.includes(plan)\n}\n\n/**\n * Per-org release-flag overrides (AGL-1635), stored on the org doc at\n * `releaseFlags`. A present key is a staff DECISION about one organization\n * and wins over both the Remote Config value and the rollout bucket; an\n * absent key inherits.\n *\n * A separate field from `entitlements.features` on purpose: those ask\n * whether the org's PLAN includes a feature, these ask whether a\n * not-yet-released feature is switched on for this one customer. Folding\n * them together would make \"granted by the deal\" and \"previewing an\n * unreleased build\" indistinguishable at the point a support question is\n * asked.\n *\n * Why the org doc and not a Remote Config condition: RC conditions are\n * template-global and would need one published condition per org, with a\n * template publish (a manual, separate deploy) for every grant.\n */\nexport type OrgReleaseFlagOverrides = Partial<Record<ReleaseFlagKey, boolean>>\n\n/**\n * Sanitises whatever is actually stored at `org.releaseFlags`.\n *\n * Tolerant for the same reason `parseReleaseFlagValue` is: this map is\n * hand-editable in the Firebase console and survives registry renames, so a\n * retired flag key or a non-boolean must be dropped rather than allowed to\n * gate anything. Unknown keys are discarded — a stale key can never grant a\n * flag that no longer exists, and a typo silently inherits instead of\n * silently forcing.\n */\nexport function parseOrgReleaseFlagOverrides(\n raw: unknown,\n): OrgReleaseFlagOverrides {\n if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}\n const overrides: OrgReleaseFlagOverrides = {}\n for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {\n if (typeof value === 'boolean' && isReleaseFlagKey(key)) {\n overrides[key] = value\n }\n }\n return overrides\n}\n\n/**\n * The gating verdict with a per-org override applied on top of\n * `isReleaseFlagOn`.\n *\n * The override is checked FIRST and short-circuits, so a forced-off flag\n * stays off for an org even while the flag is globally enabled — the\n * per-org kill switch is half the point, not just the per-org grant. Every\n * release-flag gate resolves through here so the console, the tenant\n * runtime and the API dispatchers cannot disagree about one org.\n */\nexport function isReleaseFlagOnForOrg(\n flagKey: ReleaseFlagKey,\n value: ReleaseFlagValue,\n subjectId: string | null | undefined,\n overrides: OrgReleaseFlagOverrides | null | undefined,\n /**\n * The org's plan, for tier targeting (AGL-2486). Checked only AFTER the\n * override: a staff grant is a decision about one named customer and\n * outranks the tier filter exactly as it already outranks the rollout\n * bucket. That ordering is what lets a Free org preview a\n * Business-targeted flag without widening the flag for every Free org.\n *\n * REQUIRED, and `null` has to be typed out. It was optional for one\n * release and that cost a silent revenue bug: `report-usage` held the org\n * document, dropped the argument, and every `plans`-declaring flag read\n * OFF — so the contacts overage went uninvoiced for exactly the orgs\n * entitled to reach the feature, with nothing thrown and nothing logged.\n * An unknown tier still refuses, which is the correct conservatism; what\n * cannot be allowed is a caller MANUFACTURING an unknown out of a tier it\n * already has in hand. Spelling `null` makes that a statement rather than\n * an omission, and makes the next call site that forgets fail to compile\n * instead of failing to bill. `getOrgReleaseFlagTargeting` returns the\n * plan beside the overrides from ONE document read precisely so that\n * passing it costs nothing.\n */\n plan: OrgPlan | null,\n): boolean {\n const override = overrides?.[flagKey]\n if (typeof override === 'boolean') return override\n return isReleaseFlagOn(flagKey, value, subjectId, plan)\n}\n"],"names":["PLAN_LABELS","SELF_SERVE_PLANS","PLATFORM_BRAND_NAME","PLUGIN_RELEASE_FLAGS","PLATFORM_RELEASE_FLAGS","key","label","description","defaultEnabled","RELEASE_FLAGS","RELEASE_FLAG_KEYS","map","definition","isReleaseFlagKey","value","includes","getReleaseFlagDefinition","find","entry","Error","clampPercent","percent","Number","isFinite","Math","min","max","round","RELEASE_FLAG_PLAN_LADDER","isOrgPlan","Object","prototype","hasOwnProperty","call","releaseFlagPlansAtOrAbove","plan","index","indexOf","slice","parseReleaseFlagPlans","raw","Array","isArray","undefined","named","Set","filter","size","has","parseReleaseFlagValue","fallbackEnabled","text","trim","enabled","parsed","JSON","parse","plans","rolloutPercent","note","REMOTE_CONFIG_DESCRIPTION_MAX_BYTES","utf8ByteLength","bytes","character","codePoint","codePointAt","fitsRemoteConfigDescription","DESCRIPTION_ELLIPSIS","clampRemoteConfigDescription","budget","kept","next","charAt","length","test","lastSpace","search","replace","releaseFlagParameterDescription","registryDescription","liveDescription","releaseFlagBucket","flagKey","subjectId","hash","seed","charCodeAt","imul","isReleaseFlagOn","releaseFlagTargetsPlan","parseOrgReleaseFlagOverrides","overrides","entries","isReleaseFlagOnForOrg","override"],"mappings":"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"}
|
|
@@ -110,6 +110,8 @@
|
|
|
110
110
|
// Fraud signals to staff (AGL-3356).
|
|
111
111
|
'radar.early_fraud_warning.created',
|
|
112
112
|
'review.opened',
|
|
113
|
+
// A workspace's own failed charge, read for fraud (AGL-3767).
|
|
114
|
+
'charge.failed',
|
|
113
115
|
// Register card readers (AGL-3607).
|
|
114
116
|
'terminal.reader.action_succeeded',
|
|
115
117
|
'terminal.reader.action_failed'
|
|
@@ -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 // 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":"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"}
|
|
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 // A workspace's own failed charge, read for fraud (AGL-3767).\n 'charge.failed',\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":"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"}
|
|
@@ -363,6 +363,30 @@ export interface ConsoleNavSection {
|
|
|
363
363
|
* takes over.
|
|
364
364
|
*/
|
|
365
365
|
landsOnQuery?: readonly string[];
|
|
366
|
+
/**
|
|
367
|
+
* The group this section belongs to in the rail (AGL-3713), as the words
|
|
368
|
+
* drawn above it.
|
|
369
|
+
*
|
|
370
|
+
* Consecutive sections that name the same group are drawn together: the
|
|
371
|
+
* rail puts a divider between two runs and the group's name above its run,
|
|
372
|
+
* the way MUI divides the sections of a list. A section that names none
|
|
373
|
+
* belongs to the unnamed group, which is drawn without words — so a hub
|
|
374
|
+
* whose last few sections say `group: 'Settings'` gets one divider and one
|
|
375
|
+
* subheader, and a hub that names no group is drawn exactly as before.
|
|
376
|
+
*/
|
|
377
|
+
group?: string;
|
|
378
|
+
/**
|
|
379
|
+
* Plugin zones that are this section's ONLY content (AGL-3713).
|
|
380
|
+
*
|
|
381
|
+
* Declared, the section is offered only while at least one of these zones
|
|
382
|
+
* has a widget for this reader — registered, enabled for the workspace and
|
|
383
|
+
* site, entitled and permitted — and is left out of the rail otherwise, as
|
|
384
|
+
* a section with nothing to show. A section that draws content of its own
|
|
385
|
+
* beside a zone does not declare it: it always has something to show.
|
|
386
|
+
* Until the zones' plugins have loaded the section is left out, the way an
|
|
387
|
+
* unsettled widget is withheld rather than drawn and taken away.
|
|
388
|
+
*/
|
|
389
|
+
onlyWithZones?: readonly string[];
|
|
366
390
|
}
|
|
367
391
|
/** A {@link ConsoleNavSection} with the shell's answers filled in. */
|
|
368
392
|
export interface ResolvedConsoleNavSection {
|
|
@@ -393,6 +417,9 @@ export interface ResolvedConsoleNavSection {
|
|
|
393
417
|
/** The section's declared {@link ConsoleNavSection.landsOnQuery}, carried
|
|
394
418
|
* through so the shell's landing rule can read it (AGL-3080). */
|
|
395
419
|
landsOnQuery?: readonly string[];
|
|
420
|
+
/** The section's declared {@link ConsoleNavSection.group}, carried through
|
|
421
|
+
* so the rail can divide its groups (AGL-3713). */
|
|
422
|
+
group?: string;
|
|
396
423
|
}
|
|
397
424
|
export interface ConsoleNavItem {
|
|
398
425
|
label: string;
|
|
@@ -493,6 +520,18 @@ export interface ConsoleNavItem {
|
|
|
493
520
|
* rather than leave a moved page living at two.
|
|
494
521
|
*/
|
|
495
522
|
legacyHrefs?: readonly string[];
|
|
523
|
+
/**
|
|
524
|
+
* Section ids this nav item no longer declares, each mapped to the
|
|
525
|
+
* section that took its place (AGL-3713).
|
|
526
|
+
*
|
|
527
|
+
* A section id is in links people keep, so a section that is split or
|
|
528
|
+
* renamed would otherwise turn every one of them into the shell's 404.
|
|
529
|
+
* A retired id resolves as its replacement, with `legacy: true`, so the
|
|
530
|
+
* shell replaces the address with the current one exactly as it does for
|
|
531
|
+
* a {@link legacyHrefs} match — deeper segments carried over. A mapping
|
|
532
|
+
* to an id `sections` does not declare matches nothing.
|
|
533
|
+
*/
|
|
534
|
+
retiredSections?: Readonly<Record<string, string>>;
|
|
496
535
|
/**
|
|
497
536
|
* Where this item's tab sits among the plugin tabs of its strip
|
|
498
537
|
* (AGL-3294): lower first, absent is 0, and a tie keeps registration
|
|
@@ -640,7 +640,7 @@ export function unregisterConsoleExtension(pluginId) {
|
|
|
640
640
|
return undefined;
|
|
641
641
|
}
|
|
642
642
|
function matchNavItemHref(navItem, itemHref, href) {
|
|
643
|
-
var _navItem_sections, _navItem_sections1;
|
|
643
|
+
var _navItem_sections, _navItem_sections1, _navItem_retiredSections, _navItem_sections2;
|
|
644
644
|
if (itemHref === href) return {
|
|
645
645
|
segments: []
|
|
646
646
|
};
|
|
@@ -653,6 +653,18 @@ function matchNavItemHref(navItem, itemHref, href) {
|
|
|
653
653
|
section,
|
|
654
654
|
segments
|
|
655
655
|
};
|
|
656
|
+
// A retired id (AGL-3713) answers as the section that replaced it, and is
|
|
657
|
+
// flagged so the shell moves the address on rather than serving it twice.
|
|
658
|
+
const successorId = segments[0] ? (_navItem_retiredSections = navItem.retiredSections) == null ? void 0 : _navItem_retiredSections[segments[0]] : undefined;
|
|
659
|
+
const successor = successorId ? (_navItem_sections2 = navItem.sections) == null ? void 0 : _navItem_sections2.find((item)=>item.id === successorId) : undefined;
|
|
660
|
+
if (successor) return {
|
|
661
|
+
section: successor,
|
|
662
|
+
segments: [
|
|
663
|
+
successor.id,
|
|
664
|
+
...segments.slice(1)
|
|
665
|
+
],
|
|
666
|
+
legacy: true
|
|
667
|
+
};
|
|
656
668
|
// An id the nav item never declared is NOT this page — unless the surface
|
|
657
669
|
// claimed the subtree, in which case the deeper segments are its own
|
|
658
670
|
// entity ids and it answers for them. Returning the nav item otherwise
|