@aglyn/aglyn 1.0.0-beta.200 → 1.0.0-beta.202

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/activity-presenter.d.ts +29 -3
  3. package/src/lib/app-utils/activity-presenter.js +32 -4
  4. package/src/lib/app-utils/activity-presenter.js.map +1 -1
  5. package/src/lib/app-utils/api-plugins.d.ts +20 -0
  6. package/src/lib/app-utils/api-plugins.js +9 -0
  7. package/src/lib/app-utils/api-plugins.js.map +1 -1
  8. package/src/lib/app-utils/artifact-list-keys.js +10 -0
  9. package/src/lib/app-utils/artifact-list-keys.js.map +1 -1
  10. package/src/lib/app-utils/card-payment-velocity.d.ts +57 -0
  11. package/src/lib/app-utils/card-payment-velocity.js +90 -0
  12. package/src/lib/app-utils/card-payment-velocity.js.map +1 -0
  13. package/src/lib/app-utils/crm-digest.d.ts +6 -0
  14. package/src/lib/app-utils/crm-digest.js +1 -0
  15. package/src/lib/app-utils/crm-digest.js.map +1 -1
  16. package/src/lib/app-utils/crm-task-reminders.d.ts +6 -0
  17. package/src/lib/app-utils/crm-task-reminders.js +1 -0
  18. package/src/lib/app-utils/crm-task-reminders.js.map +1 -1
  19. package/src/lib/app-utils/crm.d.ts +19 -0
  20. package/src/lib/app-utils/crm.js +27 -1
  21. package/src/lib/app-utils/crm.js.map +1 -1
  22. package/src/lib/app-utils/docs-help.generated.d.ts +4 -4
  23. package/src/lib/app-utils/docs-help.generated.js +12 -0
  24. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  25. package/src/lib/app-utils/docs-index.generated.js +85 -64
  26. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  27. package/src/lib/app-utils/email-state.d.ts +7 -3
  28. package/src/lib/app-utils/email-state.js +22 -6
  29. package/src/lib/app-utils/email-state.js.map +1 -1
  30. package/src/lib/app-utils/forms.d.ts +101 -9
  31. package/src/lib/app-utils/forms.js +137 -7
  32. package/src/lib/app-utils/forms.js.map +1 -1
  33. package/src/lib/app-utils/health-report.d.ts +3 -3
  34. package/src/lib/app-utils/health-report.js +9 -9
  35. package/src/lib/app-utils/health-report.js.map +1 -1
  36. package/src/lib/app-utils/host-naming.d.ts +12 -7
  37. package/src/lib/app-utils/host-naming.js +18 -3
  38. package/src/lib/app-utils/host-naming.js.map +1 -1
  39. package/src/lib/app-utils/lockdown.d.ts +19 -0
  40. package/src/lib/app-utils/lockdown.js +21 -0
  41. package/src/lib/app-utils/lockdown.js.map +1 -1
  42. package/src/lib/app-utils/marketplace-listing-visibility.js +5 -5
  43. package/src/lib/app-utils/marketplace-listing-visibility.js.map +1 -1
  44. package/src/lib/app-utils/media-embedded-fields.d.ts +40 -0
  45. package/src/lib/app-utils/media-embedded-fields.js +77 -0
  46. package/src/lib/app-utils/media-embedded-fields.js.map +1 -1
  47. package/src/lib/app-utils/media-metadata.d.ts +17 -1
  48. package/src/lib/app-utils/media-metadata.js +22 -2
  49. package/src/lib/app-utils/media-metadata.js.map +1 -1
  50. package/src/lib/app-utils/notifications.d.ts +35 -0
  51. package/src/lib/app-utils/notifications.js +57 -1
  52. package/src/lib/app-utils/notifications.js.map +1 -1
  53. package/src/lib/app-utils/payment-risk.d.ts +75 -0
  54. package/src/lib/app-utils/payment-risk.js +139 -0
  55. package/src/lib/app-utils/payment-risk.js.map +1 -0
  56. package/src/lib/app-utils/platform-brand.d.ts +8 -2
  57. package/src/lib/app-utils/platform-brand.js +9 -3
  58. package/src/lib/app-utils/platform-brand.js.map +1 -1
  59. package/src/lib/app-utils/plugin-api-request-host.d.ts +29 -0
  60. package/src/lib/app-utils/plugin-api-request-host.js +90 -0
  61. package/src/lib/app-utils/plugin-api-request-host.js.map +1 -0
  62. package/src/lib/app-utils/plugin-bundle-checks.d.ts +4 -2
  63. package/src/lib/app-utils/plugin-bundle-checks.js +111 -5
  64. package/src/lib/app-utils/plugin-bundle-checks.js.map +1 -1
  65. package/src/lib/app-utils/site-return-url.d.ts +55 -0
  66. package/src/lib/app-utils/site-return-url.js +129 -0
  67. package/src/lib/app-utils/site-return-url.js.map +1 -0
  68. package/src/lib/app-utils/webhook-delivery.js +4 -1
  69. package/src/lib/app-utils/webhook-delivery.js.map +1 -1
  70. package/src/lib/plugin-manager/billing-webhook-hooks.d.ts +18 -0
  71. package/src/lib/plugin-manager/billing-webhook-hooks.js +9 -1
  72. package/src/lib/plugin-manager/billing-webhook-hooks.js.map +1 -1
  73. package/src/lib/plugin-manager/plugin-activity-actions.d.ts +9 -1
  74. package/src/lib/plugin-manager/plugin-activity-actions.js +11 -1
  75. package/src/lib/plugin-manager/plugin-activity-actions.js.map +1 -1
  76. package/src/lib/plugin-manager/plugin-contact-capture.d.ts +8 -0
  77. package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
  78. package/src/lib/plugin-manager/plugin-events.d.ts +22 -0
  79. package/src/lib/plugin-manager/plugin-events.js.map +1 -1
  80. package/src/lib/plugin-manager/plugin-jobs.d.ts +8 -4
  81. package/src/lib/plugin-manager/plugin-jobs.js +8 -4
  82. package/src/lib/plugin-manager/plugin-jobs.js.map +1 -1
  83. package/src/lib/plugin-manager/plugin-recurring-charges.d.ts +39 -0
  84. package/src/lib/plugin-manager/plugin-recurring-charges.js +49 -0
  85. package/src/lib/plugin-manager/plugin-recurring-charges.js.map +1 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/site-return-url.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 * WHERE A PAYMENT SENDS THE SHOPPER BACK TO (AGL-3363).\n *\n * Every visitor payment door — buy-now, cart, reservation, booking deposit,\n * the subscription portal — hands Stripe the URL the shopper returns to\n * after paying or backing out. They built it from the request's `Referer`\n * (or `Origin`), falling back to its `Host`. All three are headers the\n * caller writes: a script that posts to a real shop's checkout with\n * `Referer: https://paypal-verify.top/` got back a GENUINE Stripe Checkout\n * link, for a real merchant, that lands the payer on the phishing page. And\n * the webhook later reads that same `success_url` as the site's origin for\n * the receipt's download links, so the lure reached the owed receipt too.\n *\n * So a return URL is accepted only when its host is the SITE'S OWN: its\n * subdomain on the tenant apex, its custom domain, or `www.` of that domain.\n * Anything else — another tenant's site, an arbitrary domain, a lookalike —\n * is replaced by the site's public origin. A host that is shaped like a\n * brand it is not (`lookalikeBrandForHost`, the phishing screen's rule) is\n * never accepted, even as the site's own custom domain: the lookalike tier\n * holds for everyone, and the site's apex subdomain still works.\n *\n * `localhost` is accepted for development; a stranger who sends a shopper\n * to their own machine has sent them nowhere.\n *\n * Pure: every door calls it with the host document it already read.\n *=========================================*/\n\nimport { lookalikeBrandForHost } from '@aglyn/shared-util-email/outbound-phishing-screen'\nimport { hostPublicOrigin, TENANT_APEX } from './host-naming'\n\n/** The site fields a return URL is judged against. */\nexport interface SiteReturnHost {\n subdomain?: string | null\n cname?: string | null\n}\n\nconst clean = (value: unknown): string =>\n typeof value === 'string' ? value.trim().toLowerCase().replace(/\\.+$/, '') : ''\n\n/** The hosts a site answers on: apex subdomain, custom domain, `www.` of it. */\nexport function siteOwnHosts(site: SiteReturnHost | null | undefined): string[] {\n const subdomain = clean(site?.subdomain)\n const cname = clean(site?.cname)\n const hosts = new Set<string>()\n if (subdomain) hosts.add(`${subdomain}.${TENANT_APEX}`)\n if (cname) {\n hosts.add(cname)\n hosts.add(cname.startsWith('www.') ? cname.slice(4) : `www.${cname}`)\n }\n return [...hosts].filter((host) => !lookalikeBrandForHost(host))\n}\n\nfunction isLocalHost(hostname: string): boolean {\n return hostname === 'localhost' || hostname.endsWith('.localhost')\n}\n\n/**\n * Is `candidate` a URL on one of the site's own hosts? https only, except\n * on localhost; no credentials in the authority (`https://shop@evil.top`).\n */\nexport function isSiteOwnUrl(\n candidate: unknown,\n site: SiteReturnHost | null | undefined,\n): boolean {\n if (typeof candidate !== 'string' || !candidate) return false\n let url: URL\n try {\n url = new URL(candidate)\n } catch {\n return false\n }\n if (url.username || url.password) return false\n const hostname = url.hostname.toLowerCase()\n if (isLocalHost(hostname)) {\n return url.protocol === 'http:' || url.protocol === 'https:'\n }\n if (url.protocol !== 'https:') return false\n return siteOwnHosts(site).includes(hostname)\n}\n\n/**\n * The URL a payment returns the shopper to: the first candidate on the\n * site's own hosts (fragment dropped), else the site's public origin, else\n * — for a site document with neither a subdomain nor a domain, which no live\n * site has — the request's own host, as before.\n *\n * `candidates` in preference order: the `Referer` (the page the shopper was\n * on), then `https://<Host>`.\n */\nexport function siteReturnUrl(input: {\n candidates: readonly (string | null | undefined)[]\n site: SiteReturnHost | null | undefined\n /** The request's `Host` header, the last resort. */\n requestHost?: string | null\n}): string {\n for (const candidate of input.candidates) {\n if (isSiteOwnUrl(candidate, input.site)) {\n const url = new URL(String(candidate))\n url.hash = ''\n return url.toString()\n }\n }\n const own = siteOwnHosts(input.site)\n const publicOrigin = hostPublicOrigin(input.site)\n if (publicOrigin && own.includes(new URL(publicOrigin).hostname)) {\n return `${publicOrigin}/`\n }\n const subdomain = clean(input.site?.subdomain)\n if (subdomain) return `https://${subdomain}.${TENANT_APEX}/`\n const requestHost = clean(input.requestHost)\n return requestHost ? `https://${requestHost}/` : '/'\n}\n\n/** {@link siteReturnUrl}'s origin, for a door that returns to the site root. */\nexport function siteReturnOrigin(input: Parameters<typeof siteReturnUrl>[0]): string {\n const url = siteReturnUrl(input)\n try {\n return new URL(url).origin\n } catch {\n return ''\n }\n}\n\n/**\n * The site fields, read from a host document reference by a door that has\n * not read it yet. A failed or missing read answers null, which keeps the\n * request's own host as the only acceptable return — never a `Referer`.\n */\nexport async function readSiteReturnHost(\n ref:\n | { get(): Promise<{ data?: () => unknown } | null | undefined> }\n | null\n | undefined,\n): Promise<SiteReturnHost | null> {\n try {\n const snapshot = await ref?.get()\n const data = snapshot?.data?.()\n return data && typeof data === 'object' ? (data as SiteReturnHost) : null\n } catch {\n return null\n }\n}\n"],"names":["lookalikeBrandForHost","hostPublicOrigin","TENANT_APEX","clean","value","trim","toLowerCase","replace","siteOwnHosts","site","subdomain","cname","hosts","Set","add","startsWith","slice","filter","host","isLocalHost","hostname","endsWith","isSiteOwnUrl","candidate","url","URL","username","password","protocol","includes","siteReturnUrl","input","candidates","String","hash","toString","own","publicOrigin","requestHost","siteReturnOrigin","origin","readSiteReturnHost","ref","snapshot","get","data"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;2CAyB2C,GAE3C,SAASA,qBAAqB,QAAQ,oDAAmD;AACzF,SAASC,gBAAgB,EAAEC,WAAW,QAAQ,mBAAe;AAQ7D,MAAMC,QAAQ,CAACC,QACb,OAAOA,UAAU,WAAWA,MAAMC,IAAI,GAAGC,WAAW,GAAGC,OAAO,CAAC,QAAQ,MAAM;AAE/E,8EAA8E,GAC9E,OAAO,SAASC,aAAaC,IAAuC;IAClE,MAAMC,YAAYP,MAAMM,wBAAAA,KAAMC,SAAS;IACvC,MAAMC,QAAQR,MAAMM,wBAAAA,KAAME,KAAK;IAC/B,MAAMC,QAAQ,IAAIC;IAClB,IAAIH,WAAWE,MAAME,GAAG,CAAC,GAAGJ,UAAU,CAAC,EAAER,aAAa;IACtD,IAAIS,OAAO;QACTC,MAAME,GAAG,CAACH;QACVC,MAAME,GAAG,CAACH,MAAMI,UAAU,CAAC,UAAUJ,MAAMK,KAAK,CAAC,KAAK,CAAC,IAAI,EAAEL,OAAO;IACtE;IACA,OAAO;WAAIC;KAAM,CAACK,MAAM,CAAC,CAACC,OAAS,CAAClB,sBAAsBkB;AAC5D;AAEA,SAASC,YAAYC,QAAgB;IACnC,OAAOA,aAAa,eAAeA,SAASC,QAAQ,CAAC;AACvD;AAEA;;;CAGC,GACD,OAAO,SAASC,aACdC,SAAkB,EAClBd,IAAuC;IAEvC,IAAI,OAAOc,cAAc,YAAY,CAACA,WAAW,OAAO;IACxD,IAAIC;IACJ,IAAI;QACFA,MAAM,IAAIC,IAAIF;IAChB,EAAE,eAAM;QACN,OAAO;IACT;IACA,IAAIC,IAAIE,QAAQ,IAAIF,IAAIG,QAAQ,EAAE,OAAO;IACzC,MAAMP,WAAWI,IAAIJ,QAAQ,CAACd,WAAW;IACzC,IAAIa,YAAYC,WAAW;QACzB,OAAOI,IAAII,QAAQ,KAAK,WAAWJ,IAAII,QAAQ,KAAK;IACtD;IACA,IAAIJ,IAAII,QAAQ,KAAK,UAAU,OAAO;IACtC,OAAOpB,aAAaC,MAAMoB,QAAQ,CAACT;AACrC;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASU,cAAcC,KAK7B;QAayBA;IAZxB,KAAK,MAAMR,aAAaQ,MAAMC,UAAU,CAAE;QACxC,IAAIV,aAAaC,WAAWQ,MAAMtB,IAAI,GAAG;YACvC,MAAMe,MAAM,IAAIC,IAAIQ,OAAOV;YAC3BC,IAAIU,IAAI,GAAG;YACX,OAAOV,IAAIW,QAAQ;QACrB;IACF;IACA,MAAMC,MAAM5B,aAAauB,MAAMtB,IAAI;IACnC,MAAM4B,eAAepC,iBAAiB8B,MAAMtB,IAAI;IAChD,IAAI4B,gBAAgBD,IAAIP,QAAQ,CAAC,IAAIJ,IAAIY,cAAcjB,QAAQ,GAAG;QAChE,OAAO,GAAGiB,aAAa,CAAC,CAAC;IAC3B;IACA,MAAM3B,YAAYP,OAAM4B,cAAAA,MAAMtB,IAAI,qBAAVsB,YAAYrB,SAAS;IAC7C,IAAIA,WAAW,OAAO,CAAC,QAAQ,EAAEA,UAAU,CAAC,EAAER,YAAY,CAAC,CAAC;IAC5D,MAAMoC,cAAcnC,MAAM4B,MAAMO,WAAW;IAC3C,OAAOA,cAAc,CAAC,QAAQ,EAAEA,YAAY,CAAC,CAAC,GAAG;AACnD;AAEA,8EAA8E,GAC9E,OAAO,SAASC,iBAAiBR,KAA0C;IACzE,MAAMP,MAAMM,cAAcC;IAC1B,IAAI;QACF,OAAO,IAAIN,IAAID,KAAKgB,MAAM;IAC5B,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA;;;;CAIC,GACD,OAAO,eAAeC,mBACpBC,GAGa;IAEb,IAAI;YAEWC;QADb,MAAMA,WAAW,OAAMD,uBAAAA,IAAKE,GAAG;QAC/B,MAAMC,OAAOF,6BAAAA,iBAAAA,SAAUE,IAAI,qBAAdF,oBAAAA;QACb,OAAOE,QAAQ,OAAOA,SAAS,WAAYA,OAA0B;IACvE,EAAE,eAAM;QACN,OAAO;IACT;AACF"}
@@ -106,7 +106,10 @@
106
106
  'invoice.marked_uncollectible',
107
107
  'customer.updated',
108
108
  'payment_method.attached',
109
- 'payment_method.detached'
109
+ 'payment_method.detached',
110
+ // Fraud signals to staff (AGL-3356).
111
+ 'radar.early_fraud_warning.created',
112
+ 'review.opened'
110
113
  ];
111
114
  /**
112
115
  * How the CONNECT destination identifies itself (AGL-1948), mirroring
@@ -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]\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[] = ['account.updated']\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]\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[] = ['account.updated']\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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"}
@@ -54,12 +54,30 @@ export interface BillingWebhookEvent {
54
54
  export interface BillingWebhookHandlerResult {
55
55
  /** True when this handler recognised the event as belonging to it. */
56
56
  claimed?: boolean;
57
+ /**
58
+ * The site the claimed record belongs to (AGL-3360), when the handler
59
+ * knows it. The route reads it for a charge's fraud signal: the seller's
60
+ * pattern row names the sites the charges were for, whichever plugin sold
61
+ * them, without the route knowing what an order or a booking is.
62
+ */
63
+ hostId?: string;
64
+ /**
65
+ * The workspace that SOLD the claimed record, when the seller is a
66
+ * workspace rather than a site (AGL-3365) — a marketplace publisher. The
67
+ * seller's fraud-pattern row names it, so a publisher's pattern reads as
68
+ * the publisher's and never as the buyer's.
69
+ */
70
+ orgId?: string;
57
71
  }
58
72
  export type BillingWebhookHandler = (event: BillingWebhookEvent) => Promise<void | BillingWebhookHandlerResult> | void | BillingWebhookHandlerResult;
59
73
  /** What `runBillingWebhookHandlers` reports back about a whole dispatch. */
60
74
  export interface BillingWebhookDispatchResult {
61
75
  /** True when ANY handler claimed the event. */
62
76
  claimed: boolean;
77
+ /** Every distinct `hostId` a handler reported, in handler order. */
78
+ hostIds: string[];
79
+ /** Every distinct seller `orgId` a handler reported, in handler order. */
80
+ orgIds: string[];
63
81
  }
64
82
  /**
65
83
  * Registers a webhook handler. The plugin loader guarantees each plugin's
@@ -39,6 +39,8 @@
39
39
  * side effects. The claim is a fold over all of them, never a break.
40
40
  */ export async function runBillingWebhookHandlers(event) {
41
41
  let claimed = false;
42
+ const hostIds = [];
43
+ const orgIds = [];
42
44
  for (const handler of handlers){
43
45
  // Narrowed through the union rather than read off it: the handler's
44
46
  // return type includes `void` — the shape every handler that does not
@@ -46,9 +48,15 @@
46
48
  // with `strictNullChecks` off.
47
49
  const result = await handler(event);
48
50
  if ((result == null ? void 0 : result.claimed) === true) claimed = true;
51
+ const hostId = typeof (result == null ? void 0 : result.hostId) === 'string' ? result.hostId : '';
52
+ if (hostId && !hostIds.includes(hostId)) hostIds.push(hostId);
53
+ const orgId = typeof (result == null ? void 0 : result.orgId) === 'string' ? result.orgId : '';
54
+ if (orgId && !orgIds.includes(orgId)) orgIds.push(orgId);
49
55
  }
50
56
  return {
51
- claimed
57
+ claimed,
58
+ hostIds,
59
+ orgIds
52
60
  };
53
61
  }
54
62
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/billing-webhook-hooks.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 * Billing-webhook extension point (AGL-418): plugins register handlers for\n * the platform Stripe webhook from their `/server` entries — the console's\n * webhook route verifies the signature, syncs the org's plan/subscription\n * (the only core-billing concern), then fans the event out here. Handlers\n * self-select on `type` + `object.metadata.type` exactly as the old inline\n * sections did (commerce orders/carts/drafts/reservations/subscriptions,\n * booking payments, marketplace purchases). Errors PROPAGATE: a throwing\n * handler fails the webhook with a 500 so Stripe redelivers — identical to\n * the pre-extraction behavior, and every section is idempotent by doc key.\n */\n\nexport interface BillingWebhookEvent {\n /** Stripe event type, e.g. 'checkout.session.completed'. */\n type: string\n /** `event.data.object` — the session/subscription/invoice payload. */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n object: any\n /** The full parsed Stripe event, for handlers that need ids/metadata. */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n event: any\n /** The webhook request's Host header — for absolute callback URLs. */\n requestHost?: string\n}\n\n/**\n * What a handler may tell the route about the event it just saw (AGL-2429).\n *\n * Handlers have always returned `void`, which made \"a plugin handled this\"\n * and \"every plugin ignored this\" the same observation from the route's side.\n * On most events that is harmless — the route does not care who consumed a\n * `checkout.session.completed`. On a CHARGEBACK it is the whole question: an\n * event nobody claimed is money that moved with nothing recording it, and it\n * is indistinguishable from the ordinary case where a plugin quietly did its\n * job. See the `charge.dispute.*` section of the console's webhook route.\n *\n * `claimed` is deliberately opt-in and deliberately narrow. A handler sets it\n * when it RECOGNISED the event as its own — found the order, the purchase,\n * the booking — not when it merely ran. Returning nothing keeps the old\n * meaning, so every handler that does not care is unaffected.\n */\nexport interface BillingWebhookHandlerResult {\n /** True when this handler recognised the event as belonging to it. */\n claimed?: boolean\n}\n\nexport type BillingWebhookHandler = (\n event: BillingWebhookEvent,\n) =>\n | Promise<void | BillingWebhookHandlerResult>\n | void\n | BillingWebhookHandlerResult\n\n/** What `runBillingWebhookHandlers` reports back about a whole dispatch. */\nexport interface BillingWebhookDispatchResult {\n /** True when ANY handler claimed the event. */\n claimed: boolean\n}\n\nconst handlers: BillingWebhookHandler[] = []\n\n/**\n * Registers a webhook handler. The plugin loader guarantees each plugin's\n * register fn runs once per process, so no dedupe is needed here.\n */\nexport function registerBillingWebhookHandler(\n handler: BillingWebhookHandler,\n): void {\n handlers.push(handler)\n}\n\n/**\n * Runs every registered handler sequentially; the first throw propagates.\n *\n * EVERY handler runs even after one has claimed the event: two plugins may\n * legitimately care about the same event, and short-circuiting on the first\n * claim would turn a reporting signal into a dispatch rule and silently drop\n * side effects. The claim is a fold over all of them, never a break.\n */\nexport async function runBillingWebhookHandlers(\n event: BillingWebhookEvent,\n): Promise<BillingWebhookDispatchResult> {\n let claimed = false\n for (const handler of handlers) {\n // Narrowed through the union rather than read off it: the handler's\n // return type includes `void` — the shape every handler that does not\n // care still returns — and a property read on `void` is a type error even\n // with `strictNullChecks` off.\n const result = (await handler(event)) as\n | BillingWebhookHandlerResult\n | undefined\n if (result?.claimed === true) claimed = true\n }\n return { claimed }\n}\n"],"names":["handlers","registerBillingWebhookHandler","handler","push","runBillingWebhookHandlers","event","claimed","result"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;CAUC,GAiDD,MAAMA,WAAoC,EAAE;AAE5C;;;CAGC,GACD,OAAO,SAASC,8BACdC,OAA8B;IAE9BF,SAASG,IAAI,CAACD;AAChB;AAEA;;;;;;;CAOC,GACD,OAAO,eAAeE,0BACpBC,KAA0B;IAE1B,IAAIC,UAAU;IACd,KAAK,MAAMJ,WAAWF,SAAU;QAC9B,oEAAoE;QACpE,sEAAsE;QACtE,0EAA0E;QAC1E,+BAA+B;QAC/B,MAAMO,SAAU,MAAML,QAAQG;QAG9B,IAAIE,CAAAA,0BAAAA,OAAQD,OAAO,MAAK,MAAMA,UAAU;IAC1C;IACA,OAAO;QAAEA;IAAQ;AACnB"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/billing-webhook-hooks.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 * Billing-webhook extension point (AGL-418): plugins register handlers for\n * the platform Stripe webhook from their `/server` entries — the console's\n * webhook route verifies the signature, syncs the org's plan/subscription\n * (the only core-billing concern), then fans the event out here. Handlers\n * self-select on `type` + `object.metadata.type` exactly as the old inline\n * sections did (commerce orders/carts/drafts/reservations/subscriptions,\n * booking payments, marketplace purchases). Errors PROPAGATE: a throwing\n * handler fails the webhook with a 500 so Stripe redelivers — identical to\n * the pre-extraction behavior, and every section is idempotent by doc key.\n */\n\nexport interface BillingWebhookEvent {\n /** Stripe event type, e.g. 'checkout.session.completed'. */\n type: string\n /** `event.data.object` — the session/subscription/invoice payload. */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n object: any\n /** The full parsed Stripe event, for handlers that need ids/metadata. */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n event: any\n /** The webhook request's Host header — for absolute callback URLs. */\n requestHost?: string\n}\n\n/**\n * What a handler may tell the route about the event it just saw (AGL-2429).\n *\n * Handlers have always returned `void`, which made \"a plugin handled this\"\n * and \"every plugin ignored this\" the same observation from the route's side.\n * On most events that is harmless — the route does not care who consumed a\n * `checkout.session.completed`. On a CHARGEBACK it is the whole question: an\n * event nobody claimed is money that moved with nothing recording it, and it\n * is indistinguishable from the ordinary case where a plugin quietly did its\n * job. See the `charge.dispute.*` section of the console's webhook route.\n *\n * `claimed` is deliberately opt-in and deliberately narrow. A handler sets it\n * when it RECOGNISED the event as its own — found the order, the purchase,\n * the booking — not when it merely ran. Returning nothing keeps the old\n * meaning, so every handler that does not care is unaffected.\n */\nexport interface BillingWebhookHandlerResult {\n /** True when this handler recognised the event as belonging to it. */\n claimed?: boolean\n /**\n * The site the claimed record belongs to (AGL-3360), when the handler\n * knows it. The route reads it for a charge's fraud signal: the seller's\n * pattern row names the sites the charges were for, whichever plugin sold\n * them, without the route knowing what an order or a booking is.\n */\n hostId?: string\n /**\n * The workspace that SOLD the claimed record, when the seller is a\n * workspace rather than a site (AGL-3365) — a marketplace publisher. The\n * seller's fraud-pattern row names it, so a publisher's pattern reads as\n * the publisher's and never as the buyer's.\n */\n orgId?: string\n}\n\nexport type BillingWebhookHandler = (\n event: BillingWebhookEvent,\n) =>\n | Promise<void | BillingWebhookHandlerResult>\n | void\n | BillingWebhookHandlerResult\n\n/** What `runBillingWebhookHandlers` reports back about a whole dispatch. */\nexport interface BillingWebhookDispatchResult {\n /** True when ANY handler claimed the event. */\n claimed: boolean\n /** Every distinct `hostId` a handler reported, in handler order. */\n hostIds: string[]\n /** Every distinct seller `orgId` a handler reported, in handler order. */\n orgIds: string[]\n}\n\nconst handlers: BillingWebhookHandler[] = []\n\n/**\n * Registers a webhook handler. The plugin loader guarantees each plugin's\n * register fn runs once per process, so no dedupe is needed here.\n */\nexport function registerBillingWebhookHandler(\n handler: BillingWebhookHandler,\n): void {\n handlers.push(handler)\n}\n\n/**\n * Runs every registered handler sequentially; the first throw propagates.\n *\n * EVERY handler runs even after one has claimed the event: two plugins may\n * legitimately care about the same event, and short-circuiting on the first\n * claim would turn a reporting signal into a dispatch rule and silently drop\n * side effects. The claim is a fold over all of them, never a break.\n */\nexport async function runBillingWebhookHandlers(\n event: BillingWebhookEvent,\n): Promise<BillingWebhookDispatchResult> {\n let claimed = false\n const hostIds: string[] = []\n const orgIds: string[] = []\n for (const handler of handlers) {\n // Narrowed through the union rather than read off it: the handler's\n // return type includes `void` — the shape every handler that does not\n // care still returns — and a property read on `void` is a type error even\n // with `strictNullChecks` off.\n const result = (await handler(event)) as\n | BillingWebhookHandlerResult\n | undefined\n if (result?.claimed === true) claimed = true\n const hostId = typeof result?.hostId === 'string' ? result.hostId : ''\n if (hostId && !hostIds.includes(hostId)) hostIds.push(hostId)\n const orgId = typeof result?.orgId === 'string' ? result.orgId : ''\n if (orgId && !orgIds.includes(orgId)) orgIds.push(orgId)\n }\n return { claimed, hostIds, orgIds }\n}\n"],"names":["handlers","registerBillingWebhookHandler","handler","push","runBillingWebhookHandlers","event","claimed","hostIds","orgIds","result","hostId","includes","orgId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;CAUC,GAmED,MAAMA,WAAoC,EAAE;AAE5C;;;CAGC,GACD,OAAO,SAASC,8BACdC,OAA8B;IAE9BF,SAASG,IAAI,CAACD;AAChB;AAEA;;;;;;;CAOC,GACD,OAAO,eAAeE,0BACpBC,KAA0B;IAE1B,IAAIC,UAAU;IACd,MAAMC,UAAoB,EAAE;IAC5B,MAAMC,SAAmB,EAAE;IAC3B,KAAK,MAAMN,WAAWF,SAAU;QAC9B,oEAAoE;QACpE,sEAAsE;QACtE,0EAA0E;QAC1E,+BAA+B;QAC/B,MAAMS,SAAU,MAAMP,QAAQG;QAG9B,IAAII,CAAAA,0BAAAA,OAAQH,OAAO,MAAK,MAAMA,UAAU;QACxC,MAAMI,SAAS,QAAOD,0BAAAA,OAAQC,MAAM,MAAK,WAAWD,OAAOC,MAAM,GAAG;QACpE,IAAIA,UAAU,CAACH,QAAQI,QAAQ,CAACD,SAASH,QAAQJ,IAAI,CAACO;QACtD,MAAME,QAAQ,QAAOH,0BAAAA,OAAQG,KAAK,MAAK,WAAWH,OAAOG,KAAK,GAAG;QACjE,IAAIA,SAAS,CAACJ,OAAOG,QAAQ,CAACC,QAAQJ,OAAOL,IAAI,CAACS;IACpD;IACA,OAAO;QAAEN;QAASC;QAASC;IAAO;AACpC"}
@@ -86,10 +86,18 @@ export interface PluginActivityRegistration {
86
86
  group: PluginActivityGroup;
87
87
  actions: readonly PluginActivityAction[];
88
88
  }
89
+ /**
90
+ * The most codes one group may hold. A group's chip asks `action is any of`
91
+ * its codes, which the feeds' queries serve as one Firestore `in` — capped at
92
+ * thirty values — so a larger group would be a chip the list always refuses.
93
+ */
94
+ export declare const PLUGIN_ACTIVITY_GROUP_MAX_ACTIONS = 30;
89
95
  /**
90
96
  * Idempotent per plugin — re-registration replaces that plugin's catalog. A
91
97
  * code already declared by a DIFFERENT plugin is refused for the whole
92
- * registration: two owners for one code would give the row two labels.
98
+ * registration: two owners for one code would give the row two labels. A
99
+ * group of more than `PLUGIN_ACTIVITY_GROUP_MAX_ACTIONS` codes is refused
100
+ * too, since its chip could never be applied.
93
101
  */
94
102
  export declare function registerPluginActivityActions(registration: PluginActivityRegistration): void;
95
103
  /** Every registration, in registration order. */
@@ -41,13 +41,23 @@
41
41
  return (_PLUGIN_TARGET_TYPE_exec = PLUGIN_TARGET_TYPE.exec(type)) == null ? void 0 : _PLUGIN_TARGET_TYPE_exec[2];
42
42
  }
43
43
  const registrations = new Map();
44
+ /**
45
+ * The most codes one group may hold. A group's chip asks `action is any of`
46
+ * its codes, which the feeds' queries serve as one Firestore `in` — capped at
47
+ * thirty values — so a larger group would be a chip the list always refuses.
48
+ */ export const PLUGIN_ACTIVITY_GROUP_MAX_ACTIONS = 30;
44
49
  /**
45
50
  * Idempotent per plugin — re-registration replaces that plugin's catalog. A
46
51
  * code already declared by a DIFFERENT plugin is refused for the whole
47
- * registration: two owners for one code would give the row two labels.
52
+ * registration: two owners for one code would give the row two labels. A
53
+ * group of more than `PLUGIN_ACTIVITY_GROUP_MAX_ACTIONS` codes is refused
54
+ * too, since its chip could never be applied.
48
55
  */ export function registerPluginActivityActions(registration) {
49
56
  const pluginId = registration.pluginId.trim();
50
57
  if (!pluginId) throw new Error('plugin activity actions need a pluginId');
58
+ if (registration.actions.length > PLUGIN_ACTIVITY_GROUP_MAX_ACTIONS) {
59
+ throw new Error(`activity group "${registration.group.id}" declares ` + `${registration.actions.length} actions; its filter chip can ask for at most ` + `${PLUGIN_ACTIVITY_GROUP_MAX_ACTIONS}, refused "${pluginId}"`);
60
+ }
51
61
  for (const action of registration.actions){
52
62
  const owner = ownerOfActivityAction(action.key);
53
63
  if (owner && owner !== pluginId) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-activity-actions.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 * Plugin-declared activity actions (AGL-2940).\n *\n * Most rows in `orgs/{orgId}/activity` and `hosts/{hostId}/activity` store\n * a prose action written at the call site. A plugin whose rows are read by\n * more than a person — a filter chip, a staff facet, an actor table — stores\n * a CODE (`ai.job.output`) and declares here what the code means: the label\n * a reader sees, the scope it is written at, and the group its filter chip\n * belongs to. The activity renderers read this catalog for labels and\n * filters, so a second plugin with coded rows gets a chip and a facet group\n * by registering, with no edit to the renderers.\n *\n * Declared at module scope from both the client barrel and the `/server`\n * entry (the AGL-428 pattern), with an explicit `pluginId`: module scope\n * runs before the loader's ownership marker is set.\n */\n\nexport type PluginActivityScope = 'org' | 'host' | 'staff'\n\n/**\n * A resource a PLUGIN files an org activity row under (AGL-2978): the\n * plugin's id, a colon and the plugin's own noun — `acme-mail:mailbox`.\n *\n * Core's activity targets are a closed list of core's own resources. A\n * plugin's resource is not one of them and is never added to that list; the\n * org log's target type accepts any namespaced type instead, and the feed\n * reads the noun after the colon for a reader. The namespace keeps two\n * plugins' nouns from meaning one thing.\n */\nexport type PluginActivityTargetType = `${string}:${string}`\n\n/** A namespaced type as the log stores it: `pluginId:noun`, both parts plain. */\nconst PLUGIN_TARGET_TYPE = /^([a-z][a-z0-9-]*):([A-Za-z][A-Za-z0-9]*)$/\n\n/** Whether a stored target type is a plugin's namespaced one. */\nexport function isPluginActivityTargetType(type: unknown): type is PluginActivityTargetType {\n return typeof type === 'string' && PLUGIN_TARGET_TYPE.test(type)\n}\n\n/**\n * The noun a plugin's namespaced type names — `acme-mail:mailbox` →\n * `mailbox` — or `undefined` for any other type.\n */\nexport function pluginActivityTargetNoun(type: unknown): string | undefined {\n if (typeof type !== 'string') return undefined\n return PLUGIN_TARGET_TYPE.exec(type)?.[2]\n}\n\nexport interface PluginActivityAction {\n /** The stored action code — dotted, stable, never a sentence. */\n key: string\n /** What a person reads for the code in the feed and the actor table. */\n label: string\n /** Which log(s) the writers put the code in. */\n scope: PluginActivityScope | readonly PluginActivityScope[]\n /** The feed target types rows carrying this code file their output under. */\n resourceTypes?: readonly string[]\n}\n\n/**\n * The filter a plugin's coded rows share: one chip in the org feed and the\n * actor table, one facet group on the staff audit page.\n */\nexport interface PluginActivityGroup {\n /** Stable group id — the facet's value (`ai`). */\n id: string\n /** The chip and the facet option (`AI`). */\n label: string\n /**\n * Staff audit actions that belong to the group without carrying one of the\n * registered codes — rows written by core on the plugin's behalf, matched\n * by prefix (`billing.assistOverage.`).\n */\n staffAuditPrefixes?: readonly string[]\n /**\n * Staff audit actions a plugin's staff doors write when staff READ\n * something rather than change it (AGL-2939): the audit log files them as\n * an access. Matched exactly.\n */\n staffAuditAccessActions?: readonly string[]\n}\n\nexport interface PluginActivityRegistration {\n pluginId: string\n group: PluginActivityGroup\n actions: readonly PluginActivityAction[]\n}\n\nconst registrations = new Map<string, PluginActivityRegistration>()\n\n/**\n * Idempotent per plugin — re-registration replaces that plugin's catalog. A\n * code already declared by a DIFFERENT plugin is refused for the whole\n * registration: two owners for one code would give the row two labels.\n */\nexport function registerPluginActivityActions(\n registration: PluginActivityRegistration,\n): void {\n const pluginId = registration.pluginId.trim()\n if (!pluginId) throw new Error('plugin activity actions need a pluginId')\n for (const action of registration.actions) {\n const owner = ownerOfActivityAction(action.key)\n if (owner && owner !== pluginId) {\n throw new Error(\n `activity action \"${action.key}\" is already declared by \"${owner}\"; ` +\n `refused \"${pluginId}\"`,\n )\n }\n }\n registrations.set(pluginId, registration)\n}\n\nfunction ownerOfActivityAction(key: string): string | undefined {\n for (const registration of registrations.values()) {\n if (registration.actions.some((action) => action.key === key)) {\n return registration.pluginId\n }\n }\n return undefined\n}\n\n/** Every registration, in registration order. */\nexport function listPluginActivityRegistrations(): PluginActivityRegistration[] {\n return [...registrations.values()]\n}\n\n/** Every declared action across plugins. */\nexport function listPluginActivityActions(): PluginActivityAction[] {\n return listPluginActivityRegistrations().flatMap((entry) => [...entry.actions])\n}\n\n/** The readable label for a declared code; `undefined` for any other action. */\nexport function pluginActivityActionLabel(action: unknown): string | undefined {\n if (typeof action !== 'string') return undefined\n for (const registration of registrations.values()) {\n const match = registration.actions.find((entry) => entry.key === action)\n if (match) return match.label\n }\n return undefined\n}\n\n/** The group a stored action's code belongs to, or `undefined`. */\nexport function pluginActivityGroupForAction(\n action: unknown,\n): PluginActivityGroup | undefined {\n if (typeof action !== 'string') return undefined\n for (const registration of registrations.values()) {\n if (registration.actions.some((entry) => entry.key === action)) {\n return registration.group\n }\n }\n return undefined\n}\n\n/**\n * The filter chips the feeds offer: one per registered group, each with the\n * codes its chip keeps. What an `action isAnyOf …` request sends.\n */\nexport function listPluginActivityFilters(): Array<{\n group: PluginActivityGroup\n actions: string[]\n}> {\n return listPluginActivityRegistrations().map((entry) => ({\n group: entry.group,\n actions: entry.actions.map((action) => action.key),\n }))\n}\n\n/**\n * The staff audit facet group for an action: a registered group when the\n * action is one of its codes or starts with one of its prefixes, else the\n * action's leading namespace (`billing`, `org`, `plugins`).\n */\nexport function pluginStaffAuditActionGroup(action: unknown): string {\n const text = typeof action === 'string' ? action.trim() : ''\n if (!text) return ''\n const byCode = pluginActivityGroupForAction(text)\n if (byCode) return byCode.id\n for (const registration of registrations.values()) {\n const prefixes = registration.group.staffAuditPrefixes ?? []\n if (prefixes.some((prefix) => text.startsWith(prefix))) {\n return registration.group.id\n }\n }\n const dot = text.indexOf('.')\n return dot > 0 ? text.slice(0, dot) : text\n}\n\n/** Whether a staff audit action is a read some plugin declared (AGL-2939). */\nexport function isPluginStaffAuditAccess(action: unknown): boolean {\n if (typeof action !== 'string' || !action) return false\n for (const registration of registrations.values()) {\n if (registration.group.staffAuditAccessActions?.includes(action)) return true\n }\n return false\n}\n\n/** How a facet group reads in the menu: a registered label, else the id. */\nexport function pluginStaffAuditActionGroupLabel(group: string): string {\n for (const registration of registrations.values()) {\n if (registration.group.id === group) return registration.group.label\n }\n return group\n}\n\n/** Test seam: forget every registration. */\nexport function resetPluginActivityActionsForTests(): void {\n registrations.clear()\n}\n"],"names":["PLUGIN_TARGET_TYPE","isPluginActivityTargetType","type","test","pluginActivityTargetNoun","undefined","exec","registrations","Map","registerPluginActivityActions","registration","pluginId","trim","Error","action","actions","owner","ownerOfActivityAction","key","set","values","some","listPluginActivityRegistrations","listPluginActivityActions","flatMap","entry","pluginActivityActionLabel","match","find","label","pluginActivityGroupForAction","group","listPluginActivityFilters","map","pluginStaffAuditActionGroup","text","byCode","id","prefixes","staffAuditPrefixes","prefix","startsWith","dot","indexOf","slice","isPluginStaffAuditAccess","staffAuditAccessActions","includes","pluginStaffAuditActionGroupLabel","resetPluginActivityActionsForTests","clear"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-activity-actions.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 * Plugin-declared activity actions (AGL-2940).\n *\n * Most rows in `orgs/{orgId}/activity` and `hosts/{hostId}/activity` store\n * a prose action written at the call site. A plugin whose rows are read by\n * more than a person — a filter chip, a staff facet, an actor table — stores\n * a CODE (`ai.job.output`) and declares here what the code means: the label\n * a reader sees, the scope it is written at, and the group its filter chip\n * belongs to. The activity renderers read this catalog for labels and\n * filters, so a second plugin with coded rows gets a chip and a facet group\n * by registering, with no edit to the renderers.\n *\n * Declared at module scope from both the client barrel and the `/server`\n * entry (the AGL-428 pattern), with an explicit `pluginId`: module scope\n * runs before the loader's ownership marker is set.\n */\n\nexport type PluginActivityScope = 'org' | 'host' | 'staff'\n\n/**\n * A resource a PLUGIN files an org activity row under (AGL-2978): the\n * plugin's id, a colon and the plugin's own noun — `acme-mail:mailbox`.\n *\n * Core's activity targets are a closed list of core's own resources. A\n * plugin's resource is not one of them and is never added to that list; the\n * org log's target type accepts any namespaced type instead, and the feed\n * reads the noun after the colon for a reader. The namespace keeps two\n * plugins' nouns from meaning one thing.\n */\nexport type PluginActivityTargetType = `${string}:${string}`\n\n/** A namespaced type as the log stores it: `pluginId:noun`, both parts plain. */\nconst PLUGIN_TARGET_TYPE = /^([a-z][a-z0-9-]*):([A-Za-z][A-Za-z0-9]*)$/\n\n/** Whether a stored target type is a plugin's namespaced one. */\nexport function isPluginActivityTargetType(type: unknown): type is PluginActivityTargetType {\n return typeof type === 'string' && PLUGIN_TARGET_TYPE.test(type)\n}\n\n/**\n * The noun a plugin's namespaced type names — `acme-mail:mailbox` →\n * `mailbox` — or `undefined` for any other type.\n */\nexport function pluginActivityTargetNoun(type: unknown): string | undefined {\n if (typeof type !== 'string') return undefined\n return PLUGIN_TARGET_TYPE.exec(type)?.[2]\n}\n\nexport interface PluginActivityAction {\n /** The stored action code — dotted, stable, never a sentence. */\n key: string\n /** What a person reads for the code in the feed and the actor table. */\n label: string\n /** Which log(s) the writers put the code in. */\n scope: PluginActivityScope | readonly PluginActivityScope[]\n /** The feed target types rows carrying this code file their output under. */\n resourceTypes?: readonly string[]\n}\n\n/**\n * The filter a plugin's coded rows share: one chip in the org feed and the\n * actor table, one facet group on the staff audit page.\n */\nexport interface PluginActivityGroup {\n /** Stable group id — the facet's value (`ai`). */\n id: string\n /** The chip and the facet option (`AI`). */\n label: string\n /**\n * Staff audit actions that belong to the group without carrying one of the\n * registered codes — rows written by core on the plugin's behalf, matched\n * by prefix (`billing.assistOverage.`).\n */\n staffAuditPrefixes?: readonly string[]\n /**\n * Staff audit actions a plugin's staff doors write when staff READ\n * something rather than change it (AGL-2939): the audit log files them as\n * an access. Matched exactly.\n */\n staffAuditAccessActions?: readonly string[]\n}\n\nexport interface PluginActivityRegistration {\n pluginId: string\n group: PluginActivityGroup\n actions: readonly PluginActivityAction[]\n}\n\nconst registrations = new Map<string, PluginActivityRegistration>()\n\n/**\n * The most codes one group may hold. A group's chip asks `action is any of`\n * its codes, which the feeds' queries serve as one Firestore `in` — capped at\n * thirty values — so a larger group would be a chip the list always refuses.\n */\nexport const PLUGIN_ACTIVITY_GROUP_MAX_ACTIONS = 30\n\n/**\n * Idempotent per plugin — re-registration replaces that plugin's catalog. A\n * code already declared by a DIFFERENT plugin is refused for the whole\n * registration: two owners for one code would give the row two labels. A\n * group of more than `PLUGIN_ACTIVITY_GROUP_MAX_ACTIONS` codes is refused\n * too, since its chip could never be applied.\n */\nexport function registerPluginActivityActions(\n registration: PluginActivityRegistration,\n): void {\n const pluginId = registration.pluginId.trim()\n if (!pluginId) throw new Error('plugin activity actions need a pluginId')\n if (registration.actions.length > PLUGIN_ACTIVITY_GROUP_MAX_ACTIONS) {\n throw new Error(\n `activity group \"${registration.group.id}\" declares ` +\n `${registration.actions.length} actions; its filter chip can ask for at most ` +\n `${PLUGIN_ACTIVITY_GROUP_MAX_ACTIONS}, refused \"${pluginId}\"`,\n )\n }\n for (const action of registration.actions) {\n const owner = ownerOfActivityAction(action.key)\n if (owner && owner !== pluginId) {\n throw new Error(\n `activity action \"${action.key}\" is already declared by \"${owner}\"; ` +\n `refused \"${pluginId}\"`,\n )\n }\n }\n registrations.set(pluginId, registration)\n}\n\nfunction ownerOfActivityAction(key: string): string | undefined {\n for (const registration of registrations.values()) {\n if (registration.actions.some((action) => action.key === key)) {\n return registration.pluginId\n }\n }\n return undefined\n}\n\n/** Every registration, in registration order. */\nexport function listPluginActivityRegistrations(): PluginActivityRegistration[] {\n return [...registrations.values()]\n}\n\n/** Every declared action across plugins. */\nexport function listPluginActivityActions(): PluginActivityAction[] {\n return listPluginActivityRegistrations().flatMap((entry) => [...entry.actions])\n}\n\n/** The readable label for a declared code; `undefined` for any other action. */\nexport function pluginActivityActionLabel(action: unknown): string | undefined {\n if (typeof action !== 'string') return undefined\n for (const registration of registrations.values()) {\n const match = registration.actions.find((entry) => entry.key === action)\n if (match) return match.label\n }\n return undefined\n}\n\n/** The group a stored action's code belongs to, or `undefined`. */\nexport function pluginActivityGroupForAction(\n action: unknown,\n): PluginActivityGroup | undefined {\n if (typeof action !== 'string') return undefined\n for (const registration of registrations.values()) {\n if (registration.actions.some((entry) => entry.key === action)) {\n return registration.group\n }\n }\n return undefined\n}\n\n/**\n * The filter chips the feeds offer: one per registered group, each with the\n * codes its chip keeps. What an `action isAnyOf …` request sends.\n */\nexport function listPluginActivityFilters(): Array<{\n group: PluginActivityGroup\n actions: string[]\n}> {\n return listPluginActivityRegistrations().map((entry) => ({\n group: entry.group,\n actions: entry.actions.map((action) => action.key),\n }))\n}\n\n/**\n * The staff audit facet group for an action: a registered group when the\n * action is one of its codes or starts with one of its prefixes, else the\n * action's leading namespace (`billing`, `org`, `plugins`).\n */\nexport function pluginStaffAuditActionGroup(action: unknown): string {\n const text = typeof action === 'string' ? action.trim() : ''\n if (!text) return ''\n const byCode = pluginActivityGroupForAction(text)\n if (byCode) return byCode.id\n for (const registration of registrations.values()) {\n const prefixes = registration.group.staffAuditPrefixes ?? []\n if (prefixes.some((prefix) => text.startsWith(prefix))) {\n return registration.group.id\n }\n }\n const dot = text.indexOf('.')\n return dot > 0 ? text.slice(0, dot) : text\n}\n\n/** Whether a staff audit action is a read some plugin declared (AGL-2939). */\nexport function isPluginStaffAuditAccess(action: unknown): boolean {\n if (typeof action !== 'string' || !action) return false\n for (const registration of registrations.values()) {\n if (registration.group.staffAuditAccessActions?.includes(action)) return true\n }\n return false\n}\n\n/** How a facet group reads in the menu: a registered label, else the id. */\nexport function pluginStaffAuditActionGroupLabel(group: string): string {\n for (const registration of registrations.values()) {\n if (registration.group.id === group) return registration.group.label\n }\n return group\n}\n\n/** Test seam: forget every registration. */\nexport function resetPluginActivityActionsForTests(): void {\n registrations.clear()\n}\n"],"names":["PLUGIN_TARGET_TYPE","isPluginActivityTargetType","type","test","pluginActivityTargetNoun","undefined","exec","registrations","Map","PLUGIN_ACTIVITY_GROUP_MAX_ACTIONS","registerPluginActivityActions","registration","pluginId","trim","Error","actions","length","group","id","action","owner","ownerOfActivityAction","key","set","values","some","listPluginActivityRegistrations","listPluginActivityActions","flatMap","entry","pluginActivityActionLabel","match","find","label","pluginActivityGroupForAction","listPluginActivityFilters","map","pluginStaffAuditActionGroup","text","byCode","prefixes","staffAuditPrefixes","prefix","startsWith","dot","indexOf","slice","isPluginStaffAuditAccess","staffAuditAccessActions","includes","pluginStaffAuditActionGroupLabel","resetPluginActivityActionsForTests","clear"],"mappings":"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"}
@@ -198,6 +198,14 @@ export type PluginContactCaptured =
198
198
  record: 'lead';
199
199
  leadId: string;
200
200
  created: boolean;
201
+ /**
202
+ * Whether this capture's surface was new on the lead — a new person,
203
+ * or one first met through another door. A form counts its leads by
204
+ * it (AGL-3330): the people it filed, not the times they came back.
205
+ * Absent from a writer that does not say, which a caller reads as
206
+ * `created`.
207
+ */
208
+ sourceAdded?: boolean;
201
209
  } | {
202
210
  ok: false;
203
211
  /**
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-contact-capture.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\nimport {\n definePluginServiceContract,\n registerPluginService,\n resolvePluginServices,\n} from './plugin-services'\n\n/**\n * A person met on a site, handed to whichever plugin keeps people (AGL-3124).\n *\n * Four silos capture the same thing and none of them is the record system: a\n * form submission, a site member signing up, an order, a booking. Each holds\n * an address, a name and the fact that something happened, and each wants the\n * workspace's person record to know. Today each one imports the writer, which\n * makes the highest-fan-in edge in the repo — thirty files in one plugin alone\n * — and makes the record system's plugin something a storefront cannot take a\n * payment without.\n *\n * So the silo stops importing and starts DECLARING: it registers the capture\n * door it owns, and hands a capture to this seam. The plugin that keeps people\n * registers the writer and does every part of the work that is a record\n * system's to do:\n *\n * - normalizing and keying the address, and deciding whether this is a new\n * person or a visit from one the workspace already holds;\n * - the audience band, the erasure rows, and every other refusal — answered,\n * never thrown, so a capture that is refused never costs the silo the order\n * or the submission it was recording;\n * - what a stage, an owner, a company and a tag mean, and what the arrival\n * of a new person sets off.\n *\n * The silo decides none of that. It reports what it saw.\n *\n * ## Why the profile is opaque\n *\n * `profile` is the owner's own field names with scalar values, not a core\n * type. A person's record shape belongs to the plugin that models it, and a\n * typed core shape would be that model spelled in the platform again — which\n * is the defect this seam exists to retire, not a smaller version of it. A key\n * the owner does not document is the owner's to ignore.\n *\n * `source` is the silo's own word for its door, and it is declared rather than\n * enumerated here for the same reason: core listing `form`, `member`, `order`\n * and `booking` would be core holding the plugin set again. A silo registers\n * its source with {@link registerPluginContactSource}, which is what lets a\n * timeline label an entry and a link point back at the thing that captured the\n * person, without the platform knowing what any of them are.\n *\n * ## One writer; many silos\n *\n * A workspace keeps one set of people, so the writer is a slot: a second\n * plugin's writer is refused naming both, and the incumbent keeps serving. A\n * SOURCE is per silo and there are many, but one source word has one owner —\n * two plugins claiming `order` would make the label and the link ambiguous at\n * the point they are read.\n *\n * ## The caller proves who is asking\n *\n * Like `plugin-record-timeline`, the registry authenticates nobody. A silo has\n * already decided, in its own terms and before it asks, that the capture is\n * the workspace's to record — that the submission was accepted, that the order\n * was paid.\n *\n * Server-side: reached by its own subpath, never through\n * `plugin-manager/index.ts`.\n */\n\n/** The person, as the capturing silo saw them. */\nexport interface PluginContactIdentity {\n /**\n * The address they gave. Raw: the owner normalizes and keys on it, and a\n * silo that normalized first would be keeping a second copy of that rule.\n */\n email: unknown\n name?: string | null\n}\n\n/** What happened, in the capturing silo's own words. */\nexport interface PluginContactInteraction {\n /** The silo's declared source word — see {@link registerPluginContactSource}. */\n source: string\n /** When it happened, epoch ms; the owner uses now when absent. */\n atMs?: number\n /**\n * The document it happened on, in the silo's terms\n * (`formSubmissions/{id}`, `orders/{id}`), so a timeline entry can point\n * back at it. Absent where the door leaves nothing to open.\n */\n refId?: string | null\n /** One line a person reads on the timeline. */\n summary?: string | null\n}\n\nexport interface PluginContactCaptureRequest {\n orgId: string\n /** The site the person was met on: the brand whose form or checkout it was. */\n hostId: string\n identity: PluginContactIdentity\n interaction: PluginContactInteraction\n /**\n * An explicit marketing opt-in the capture surface carried, recorded against\n * `hostId`. Absent is not consent, and filing a capture under a campaign is\n * not consent either.\n */\n marketingConsent?: boolean\n /**\n * The disclosure key the capture surface rendered beside that opt-in\n * (`consentGroupDisclosureKey`, AGL-3320), passed through as the surface\n * sent it. The owner records the opt-in for the site's whole consent group\n * only when this is the group's current key, and for `hostId` alone\n * otherwise — so a silo whose surface shows no disclosure sends none.\n */\n disclosedConsentGroup?: string\n /** Tags the silo puts on this capture; the owner adds, never replaces. */\n tags?: readonly string[]\n /** The campaigns the capture SURFACE is filed under — the merchant's own act. */\n campaignIds?: readonly string[]\n /** Money the capture was worth, in cents, where the silo took some. */\n purchaseCents?: number\n /** The currency {@link purchaseCents} is in, lowercase, when the silo knows. */\n purchaseCurrency?: string\n /**\n * The earliest stage this capture describes, in the OWNER's words. A floor\n * and not a value: the owner fills an empty stage and advances an earlier\n * one, and never moves anybody back, so a repeat customer filling in a\n * contact form is still a customer.\n */\n lifecycleFloor?: string\n /**\n * What kind of door this is, which decides WHICH RECORD the person lands\n * on (AGL-3232) — the owner's rule is Salesforce's: one person is one\n * record, a lead until they are qualified and a contact after.\n *\n * - `lead`: a lead surface — a form whose author routes it to leads, a\n * booking request. The person is filed as a LEAD and nothing else,\n * unless the workspace already holds them as a contact, in which case\n * the capture lands on the contact and no lead is filed.\n * - `relationship`: an act that makes the person a known relationship —\n * a member account, a purchase. The person is filed as a CONTACT, and\n * an open lead the site held for them is stamped converted onto it.\n * - `touch`, the default: everything else — a form without lead routing,\n * a newsletter opt-in. The capture lands on the open lead when the site\n * holds one for the address, and on the contact otherwise.\n */\n surface?: 'lead' | 'relationship' | 'touch'\n /**\n * Profile fields the silo knows, keyed by the owner's own field names.\n * Only the keys given are written, so a silo that knows the phone number\n * does not blank a title somebody typed.\n */\n profile?: Readonly<Record<string, unknown>>\n /**\n * Facts about THIS CAPTURE that the owner models and the platform does not\n * (AGL-3080) — which form was filled in, which page it was on, where the\n * visitor arrived from.\n *\n * Opaque for the reason {@link profile} is: every one of these is a concept\n * belonging to the plugin that keeps people or to the silo that saw it, and\n * a typed core field for each would be those models spelled out in the\n * platform again. `profile` describes the PERSON and outlives the visit;\n * this describes the visit and does not.\n *\n * A key the owner does not recognize is the owner's to ignore, and a silo\n * that sends nothing here loses nothing: every field is an enrichment of a\n * capture that is already complete without it.\n */\n detail?: Readonly<Record<string, unknown>>\n}\n\n/**\n * The owner's answer. `created: false` is a visit by somebody the workspace\n * already held, which is an interaction and not a new person.\n *\n * A refusal is RETURNED. Every one of these silos has already done the thing\n * it was recording, so a throw here would cost an accepted submission or a\n * paid order for a record it could not keep.\n */\nexport type PluginContactCaptured =\n /** The capture landed on a contact — the record every capture used to make. */\n | { ok: true; record: 'contact'; contactId: string; created: boolean }\n /**\n * The capture landed on a LEAD (AGL-3232): a lead surface met somebody the\n * workspace does not hold as a contact, or a touch reached an open lead.\n * `leadId` is the person key the lead is filed under on `hostId`.\n */\n | { ok: true; record: 'lead'; leadId: string; created: boolean }\n | {\n ok: false\n /**\n * `invalid-email`: nothing usable to key a person on. `band`: the\n * workspace is at the audience it may hold. `erased`: the workspace\n * erased this person and a capture must not quietly rebuild them.\n * `error`: the owner failed, and said so rather than silently.\n */\n reason: 'invalid-email' | 'band' | 'erased' | 'error'\n /** Customer-safe: a caller may show or log it as it stands. */\n error: string\n }\n\nexport interface PluginContactCaptureWriter {\n /** Records one capture; a refusal is returned, never thrown. */\n capture(\n request: PluginContactCaptureRequest,\n ): Promise<PluginContactCaptured>\n}\n\n/** A capture door a silo owns. */\nexport interface PluginContactSourceDeclaration {\n /** The silo's word for the door, as its captures carry it. */\n source: string\n /** What a timeline entry from this door says: `Form submission`. */\n label: string\n /**\n * What a link from that entry says when there is something to open —\n * `Open submission`. Absent where the door leaves nothing to open, which\n * is the honest answer for an import or a by-hand add.\n */\n openLabel?: string\n /**\n * The record kind the silo's own document is, for\n * `plugin-record-routes` to address it with. Absent where the door has no\n * record of its own to open.\n */\n recordKind?: string\n}\n\n/** A source declaration with the silo that made it. */\nexport type ResolvedPluginContactSource = PluginContactSourceDeclaration & {\n pluginId: string\n}\n\nexport const PLUGIN_CONTACT_CAPTURE =\n definePluginServiceContract<PluginContactCaptureWriter>(\n 'core.contact-capture',\n { multiple: false },\n )\n\nexport const PLUGIN_CONTACT_SOURCES =\n definePluginServiceContract<PluginContactSourceDeclaration>(\n 'core.contact-capture-sources',\n { multiple: true },\n )\n\n/**\n * Registers the plugin that keeps people. The owner is the loader's marker\n * when a register fn is running, else `options.pluginId`; with neither the\n * registration throws, and a second plugin's writer is refused naming both —\n * the incumbent keeps serving.\n */\nexport function registerPluginContactCaptureWriter(\n writer: PluginContactCaptureWriter,\n options?: { pluginId?: string },\n): void {\n registerPluginService(PLUGIN_CONTACT_CAPTURE, writer, {\n ...(options?.pluginId ? { pluginId: options.pluginId } : {}),\n })\n}\n\n/** Declares a capture door. One source word has one owner. */\nexport function registerPluginContactSource(\n declaration: PluginContactSourceDeclaration,\n options?: { pluginId?: string },\n): void {\n const key = declaration.source?.trim() ?? ''\n if (!key) throw new Error('a contact capture source needs a source word')\n if (!declaration.label?.trim()) {\n throw new Error(`contact capture source \"${key}\" needs a label`)\n }\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n const incumbent = resolvePluginServices(PLUGIN_CONTACT_SOURCES).find(\n (entry) => entry.key === key,\n )\n if (incumbent && pluginId && incumbent.pluginId !== pluginId) {\n throw new Error(\n `contact capture source \"${key}\" is already declared by ` +\n `\"${incumbent.pluginId}\"; refused \"${pluginId}\"`,\n )\n }\n registerPluginService(\n PLUGIN_CONTACT_SOURCES,\n { ...declaration, source: key },\n {\n ...(options?.pluginId ? { pluginId: options.pluginId } : {}),\n key,\n },\n )\n}\n\nexport interface ResolvedPluginContactCaptureWriter {\n /** The plugin that keeps people. */\n pluginId: string\n writer: PluginContactCaptureWriter\n}\n\n/** The writer with its owner, or `null` when no plugin keeps people here. */\nexport function pluginContactCaptureWriter(): ResolvedPluginContactCaptureWriter | null {\n const entry = resolvePluginServices(PLUGIN_CONTACT_CAPTURE)[0]\n return entry ? { pluginId: entry.pluginId, writer: entry.impl } : null\n}\n\n/**\n * Hands one capture to the plugin that keeps people, or answers `null` when\n * no plugin does.\n *\n * `null` and a refusal are different answers and a silo treats them\n * differently: `null` is a workspace with no record system, where there is\n * nothing to record and nothing has gone wrong; a refusal is a record system\n * that considered this person and declined.\n */\nexport async function capturePluginContact(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured | null> {\n const found = pluginContactCaptureWriter()\n if (!found) return null\n return found.writer.capture(request)\n}\n\n/** Every declared capture door, with its silo, in registration order. */\nexport function listPluginContactSources(): ResolvedPluginContactSource[] {\n return resolvePluginServices(PLUGIN_CONTACT_SOURCES).map((entry) => ({\n ...entry.impl,\n pluginId: entry.pluginId,\n }))\n}\n\n/** One declared capture door by its source word, or `null`. */\nexport function pluginContactSource(\n source: string,\n): ResolvedPluginContactSource | null {\n const key = source.trim()\n const entry = resolvePluginServices(PLUGIN_CONTACT_SOURCES).find(\n (one) => one.key === key,\n )\n return entry ? { ...entry.impl, pluginId: entry.pluginId } : null\n}\n"],"names":["getRegisteringPluginId","definePluginServiceContract","registerPluginService","resolvePluginServices","PLUGIN_CONTACT_CAPTURE","multiple","PLUGIN_CONTACT_SOURCES","registerPluginContactCaptureWriter","writer","options","pluginId","registerPluginContactSource","declaration","key","source","trim","Error","label","incumbent","find","entry","pluginContactCaptureWriter","impl","capturePluginContact","request","found","capture","listPluginContactSources","map","pluginContactSource","one"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,sBAAsB,QAAQ,qCAAiC;AACxE,SACEC,2BAA2B,EAC3BC,qBAAqB,EACrBC,qBAAqB,QAChB,uBAAmB;AAkO1B,OAAO,MAAMC,yBACXH,4BACE,wBACA;IAAEI,UAAU;AAAM,GACnB;AAEH,OAAO,MAAMC,yBACXL,4BACE,gCACA;IAAEI,UAAU;AAAK,GAClB;AAEH;;;;;CAKC,GACD,OAAO,SAASE,mCACdC,MAAkC,EAClCC,OAA+B;IAE/BP,sBAAsBE,wBAAwBI,QAAQ,aAChDC,CAAAA,2BAAAA,QAASC,QAAQ,IAAG;QAAEA,UAAUD,QAAQC,QAAQ;IAAC,IAAI,CAAC;AAE9D;AAEA,4DAA4D,GAC5D,OAAO,SAASC,4BACdC,WAA2C,EAC3CH,OAA+B;cAObT,OAAAA;QALNY,qBAEPA;IAFL,MAAMC,eAAMD,sBAAAA,YAAYE,MAAM,qBAAlBF,oBAAoBG,IAAI,qBAAM;IAC1C,IAAI,CAACF,KAAK,MAAM,IAAIG,MAAM;IAC1B,IAAI,GAACJ,qBAAAA,YAAYK,KAAK,qBAAjBL,mBAAmBG,IAAI,KAAI;QAC9B,MAAM,IAAIC,MAAM,CAAC,wBAAwB,EAAEH,IAAI,eAAe,CAAC;IACjE;IACA,MAAMH,WAAW,EAACV,SAAAA,0BAAAA,oCAAAA,0BAA4BS,2BAAAA,QAASC,QAAQ,YAA7CV,QAAiD,IAAIe,IAAI;IAC3E,MAAMG,YAAYf,sBAAsBG,wBAAwBa,IAAI,CAClE,CAACC,QAAUA,MAAMP,GAAG,KAAKA;IAE3B,IAAIK,aAAaR,YAAYQ,UAAUR,QAAQ,KAAKA,UAAU;QAC5D,MAAM,IAAIM,MACR,CAAC,wBAAwB,EAAEH,IAAI,yBAAyB,CAAC,GACvD,CAAC,CAAC,EAAEK,UAAUR,QAAQ,CAAC,YAAY,EAAEA,SAAS,CAAC,CAAC;IAEtD;IACAR,sBACEI,wBACA,aAAKM;QAAaE,QAAQD;QAC1B,aACMJ,CAAAA,2BAAAA,QAASC,QAAQ,IAAG;QAAEA,UAAUD,QAAQC,QAAQ;IAAC,IAAI,CAAC;QAC1DG;;AAGN;AAQA,2EAA2E,GAC3E,OAAO,SAASQ;IACd,MAAMD,QAAQjB,sBAAsBC,uBAAuB,CAAC,EAAE;IAC9D,OAAOgB,QAAQ;QAAEV,UAAUU,MAAMV,QAAQ;QAAEF,QAAQY,MAAME,IAAI;IAAC,IAAI;AACpE;AAEA;;;;;;;;CAQC,GACD,OAAO,eAAeC,qBACpBC,OAAoC;IAEpC,MAAMC,QAAQJ;IACd,IAAI,CAACI,OAAO,OAAO;IACnB,OAAOA,MAAMjB,MAAM,CAACkB,OAAO,CAACF;AAC9B;AAEA,uEAAuE,GACvE,OAAO,SAASG;IACd,OAAOxB,sBAAsBG,wBAAwBsB,GAAG,CAAC,CAACR,QAAW,aAChEA,MAAME,IAAI;YACbZ,UAAUU,MAAMV,QAAQ;;AAE5B;AAEA,6DAA6D,GAC7D,OAAO,SAASmB,oBACdf,MAAc;IAEd,MAAMD,MAAMC,OAAOC,IAAI;IACvB,MAAMK,QAAQjB,sBAAsBG,wBAAwBa,IAAI,CAC9D,CAACW,MAAQA,IAAIjB,GAAG,KAAKA;IAEvB,OAAOO,QAAQ,aAAKA,MAAME,IAAI;QAAEZ,UAAUU,MAAMV,QAAQ;SAAK;AAC/D"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-contact-capture.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\nimport {\n definePluginServiceContract,\n registerPluginService,\n resolvePluginServices,\n} from './plugin-services'\n\n/**\n * A person met on a site, handed to whichever plugin keeps people (AGL-3124).\n *\n * Four silos capture the same thing and none of them is the record system: a\n * form submission, a site member signing up, an order, a booking. Each holds\n * an address, a name and the fact that something happened, and each wants the\n * workspace's person record to know. Today each one imports the writer, which\n * makes the highest-fan-in edge in the repo — thirty files in one plugin alone\n * — and makes the record system's plugin something a storefront cannot take a\n * payment without.\n *\n * So the silo stops importing and starts DECLARING: it registers the capture\n * door it owns, and hands a capture to this seam. The plugin that keeps people\n * registers the writer and does every part of the work that is a record\n * system's to do:\n *\n * - normalizing and keying the address, and deciding whether this is a new\n * person or a visit from one the workspace already holds;\n * - the audience band, the erasure rows, and every other refusal — answered,\n * never thrown, so a capture that is refused never costs the silo the order\n * or the submission it was recording;\n * - what a stage, an owner, a company and a tag mean, and what the arrival\n * of a new person sets off.\n *\n * The silo decides none of that. It reports what it saw.\n *\n * ## Why the profile is opaque\n *\n * `profile` is the owner's own field names with scalar values, not a core\n * type. A person's record shape belongs to the plugin that models it, and a\n * typed core shape would be that model spelled in the platform again — which\n * is the defect this seam exists to retire, not a smaller version of it. A key\n * the owner does not document is the owner's to ignore.\n *\n * `source` is the silo's own word for its door, and it is declared rather than\n * enumerated here for the same reason: core listing `form`, `member`, `order`\n * and `booking` would be core holding the plugin set again. A silo registers\n * its source with {@link registerPluginContactSource}, which is what lets a\n * timeline label an entry and a link point back at the thing that captured the\n * person, without the platform knowing what any of them are.\n *\n * ## One writer; many silos\n *\n * A workspace keeps one set of people, so the writer is a slot: a second\n * plugin's writer is refused naming both, and the incumbent keeps serving. A\n * SOURCE is per silo and there are many, but one source word has one owner —\n * two plugins claiming `order` would make the label and the link ambiguous at\n * the point they are read.\n *\n * ## The caller proves who is asking\n *\n * Like `plugin-record-timeline`, the registry authenticates nobody. A silo has\n * already decided, in its own terms and before it asks, that the capture is\n * the workspace's to record — that the submission was accepted, that the order\n * was paid.\n *\n * Server-side: reached by its own subpath, never through\n * `plugin-manager/index.ts`.\n */\n\n/** The person, as the capturing silo saw them. */\nexport interface PluginContactIdentity {\n /**\n * The address they gave. Raw: the owner normalizes and keys on it, and a\n * silo that normalized first would be keeping a second copy of that rule.\n */\n email: unknown\n name?: string | null\n}\n\n/** What happened, in the capturing silo's own words. */\nexport interface PluginContactInteraction {\n /** The silo's declared source word — see {@link registerPluginContactSource}. */\n source: string\n /** When it happened, epoch ms; the owner uses now when absent. */\n atMs?: number\n /**\n * The document it happened on, in the silo's terms\n * (`formSubmissions/{id}`, `orders/{id}`), so a timeline entry can point\n * back at it. Absent where the door leaves nothing to open.\n */\n refId?: string | null\n /** One line a person reads on the timeline. */\n summary?: string | null\n}\n\nexport interface PluginContactCaptureRequest {\n orgId: string\n /** The site the person was met on: the brand whose form or checkout it was. */\n hostId: string\n identity: PluginContactIdentity\n interaction: PluginContactInteraction\n /**\n * An explicit marketing opt-in the capture surface carried, recorded against\n * `hostId`. Absent is not consent, and filing a capture under a campaign is\n * not consent either.\n */\n marketingConsent?: boolean\n /**\n * The disclosure key the capture surface rendered beside that opt-in\n * (`consentGroupDisclosureKey`, AGL-3320), passed through as the surface\n * sent it. The owner records the opt-in for the site's whole consent group\n * only when this is the group's current key, and for `hostId` alone\n * otherwise — so a silo whose surface shows no disclosure sends none.\n */\n disclosedConsentGroup?: string\n /** Tags the silo puts on this capture; the owner adds, never replaces. */\n tags?: readonly string[]\n /** The campaigns the capture SURFACE is filed under — the merchant's own act. */\n campaignIds?: readonly string[]\n /** Money the capture was worth, in cents, where the silo took some. */\n purchaseCents?: number\n /** The currency {@link purchaseCents} is in, lowercase, when the silo knows. */\n purchaseCurrency?: string\n /**\n * The earliest stage this capture describes, in the OWNER's words. A floor\n * and not a value: the owner fills an empty stage and advances an earlier\n * one, and never moves anybody back, so a repeat customer filling in a\n * contact form is still a customer.\n */\n lifecycleFloor?: string\n /**\n * What kind of door this is, which decides WHICH RECORD the person lands\n * on (AGL-3232) — the owner's rule is Salesforce's: one person is one\n * record, a lead until they are qualified and a contact after.\n *\n * - `lead`: a lead surface — a form whose author routes it to leads, a\n * booking request. The person is filed as a LEAD and nothing else,\n * unless the workspace already holds them as a contact, in which case\n * the capture lands on the contact and no lead is filed.\n * - `relationship`: an act that makes the person a known relationship —\n * a member account, a purchase. The person is filed as a CONTACT, and\n * an open lead the site held for them is stamped converted onto it.\n * - `touch`, the default: everything else — a form without lead routing,\n * a newsletter opt-in. The capture lands on the open lead when the site\n * holds one for the address, and on the contact otherwise.\n */\n surface?: 'lead' | 'relationship' | 'touch'\n /**\n * Profile fields the silo knows, keyed by the owner's own field names.\n * Only the keys given are written, so a silo that knows the phone number\n * does not blank a title somebody typed.\n */\n profile?: Readonly<Record<string, unknown>>\n /**\n * Facts about THIS CAPTURE that the owner models and the platform does not\n * (AGL-3080) — which form was filled in, which page it was on, where the\n * visitor arrived from.\n *\n * Opaque for the reason {@link profile} is: every one of these is a concept\n * belonging to the plugin that keeps people or to the silo that saw it, and\n * a typed core field for each would be those models spelled out in the\n * platform again. `profile` describes the PERSON and outlives the visit;\n * this describes the visit and does not.\n *\n * A key the owner does not recognize is the owner's to ignore, and a silo\n * that sends nothing here loses nothing: every field is an enrichment of a\n * capture that is already complete without it.\n */\n detail?: Readonly<Record<string, unknown>>\n}\n\n/**\n * The owner's answer. `created: false` is a visit by somebody the workspace\n * already held, which is an interaction and not a new person.\n *\n * A refusal is RETURNED. Every one of these silos has already done the thing\n * it was recording, so a throw here would cost an accepted submission or a\n * paid order for a record it could not keep.\n */\nexport type PluginContactCaptured =\n /** The capture landed on a contact — the record every capture used to make. */\n | { ok: true; record: 'contact'; contactId: string; created: boolean }\n /**\n * The capture landed on a LEAD (AGL-3232): a lead surface met somebody the\n * workspace does not hold as a contact, or a touch reached an open lead.\n * `leadId` is the person key the lead is filed under on `hostId`.\n */\n | {\n ok: true\n record: 'lead'\n leadId: string\n created: boolean\n /**\n * Whether this capture's surface was new on the lead — a new person,\n * or one first met through another door. A form counts its leads by\n * it (AGL-3330): the people it filed, not the times they came back.\n * Absent from a writer that does not say, which a caller reads as\n * `created`.\n */\n sourceAdded?: boolean\n }\n | {\n ok: false\n /**\n * `invalid-email`: nothing usable to key a person on. `band`: the\n * workspace is at the audience it may hold. `erased`: the workspace\n * erased this person and a capture must not quietly rebuild them.\n * `error`: the owner failed, and said so rather than silently.\n */\n reason: 'invalid-email' | 'band' | 'erased' | 'error'\n /** Customer-safe: a caller may show or log it as it stands. */\n error: string\n }\n\nexport interface PluginContactCaptureWriter {\n /** Records one capture; a refusal is returned, never thrown. */\n capture(\n request: PluginContactCaptureRequest,\n ): Promise<PluginContactCaptured>\n}\n\n/** A capture door a silo owns. */\nexport interface PluginContactSourceDeclaration {\n /** The silo's word for the door, as its captures carry it. */\n source: string\n /** What a timeline entry from this door says: `Form submission`. */\n label: string\n /**\n * What a link from that entry says when there is something to open —\n * `Open submission`. Absent where the door leaves nothing to open, which\n * is the honest answer for an import or a by-hand add.\n */\n openLabel?: string\n /**\n * The record kind the silo's own document is, for\n * `plugin-record-routes` to address it with. Absent where the door has no\n * record of its own to open.\n */\n recordKind?: string\n}\n\n/** A source declaration with the silo that made it. */\nexport type ResolvedPluginContactSource = PluginContactSourceDeclaration & {\n pluginId: string\n}\n\nexport const PLUGIN_CONTACT_CAPTURE =\n definePluginServiceContract<PluginContactCaptureWriter>(\n 'core.contact-capture',\n { multiple: false },\n )\n\nexport const PLUGIN_CONTACT_SOURCES =\n definePluginServiceContract<PluginContactSourceDeclaration>(\n 'core.contact-capture-sources',\n { multiple: true },\n )\n\n/**\n * Registers the plugin that keeps people. The owner is the loader's marker\n * when a register fn is running, else `options.pluginId`; with neither the\n * registration throws, and a second plugin's writer is refused naming both —\n * the incumbent keeps serving.\n */\nexport function registerPluginContactCaptureWriter(\n writer: PluginContactCaptureWriter,\n options?: { pluginId?: string },\n): void {\n registerPluginService(PLUGIN_CONTACT_CAPTURE, writer, {\n ...(options?.pluginId ? { pluginId: options.pluginId } : {}),\n })\n}\n\n/** Declares a capture door. One source word has one owner. */\nexport function registerPluginContactSource(\n declaration: PluginContactSourceDeclaration,\n options?: { pluginId?: string },\n): void {\n const key = declaration.source?.trim() ?? ''\n if (!key) throw new Error('a contact capture source needs a source word')\n if (!declaration.label?.trim()) {\n throw new Error(`contact capture source \"${key}\" needs a label`)\n }\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n const incumbent = resolvePluginServices(PLUGIN_CONTACT_SOURCES).find(\n (entry) => entry.key === key,\n )\n if (incumbent && pluginId && incumbent.pluginId !== pluginId) {\n throw new Error(\n `contact capture source \"${key}\" is already declared by ` +\n `\"${incumbent.pluginId}\"; refused \"${pluginId}\"`,\n )\n }\n registerPluginService(\n PLUGIN_CONTACT_SOURCES,\n { ...declaration, source: key },\n {\n ...(options?.pluginId ? { pluginId: options.pluginId } : {}),\n key,\n },\n )\n}\n\nexport interface ResolvedPluginContactCaptureWriter {\n /** The plugin that keeps people. */\n pluginId: string\n writer: PluginContactCaptureWriter\n}\n\n/** The writer with its owner, or `null` when no plugin keeps people here. */\nexport function pluginContactCaptureWriter(): ResolvedPluginContactCaptureWriter | null {\n const entry = resolvePluginServices(PLUGIN_CONTACT_CAPTURE)[0]\n return entry ? { pluginId: entry.pluginId, writer: entry.impl } : null\n}\n\n/**\n * Hands one capture to the plugin that keeps people, or answers `null` when\n * no plugin does.\n *\n * `null` and a refusal are different answers and a silo treats them\n * differently: `null` is a workspace with no record system, where there is\n * nothing to record and nothing has gone wrong; a refusal is a record system\n * that considered this person and declined.\n */\nexport async function capturePluginContact(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured | null> {\n const found = pluginContactCaptureWriter()\n if (!found) return null\n return found.writer.capture(request)\n}\n\n/** Every declared capture door, with its silo, in registration order. */\nexport function listPluginContactSources(): ResolvedPluginContactSource[] {\n return resolvePluginServices(PLUGIN_CONTACT_SOURCES).map((entry) => ({\n ...entry.impl,\n pluginId: entry.pluginId,\n }))\n}\n\n/** One declared capture door by its source word, or `null`. */\nexport function pluginContactSource(\n source: string,\n): ResolvedPluginContactSource | null {\n const key = source.trim()\n const entry = resolvePluginServices(PLUGIN_CONTACT_SOURCES).find(\n (one) => one.key === key,\n )\n return entry ? { ...entry.impl, pluginId: entry.pluginId } : null\n}\n"],"names":["getRegisteringPluginId","definePluginServiceContract","registerPluginService","resolvePluginServices","PLUGIN_CONTACT_CAPTURE","multiple","PLUGIN_CONTACT_SOURCES","registerPluginContactCaptureWriter","writer","options","pluginId","registerPluginContactSource","declaration","key","source","trim","Error","label","incumbent","find","entry","pluginContactCaptureWriter","impl","capturePluginContact","request","found","capture","listPluginContactSources","map","pluginContactSource","one"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,sBAAsB,QAAQ,qCAAiC;AACxE,SACEC,2BAA2B,EAC3BC,qBAAqB,EACrBC,qBAAqB,QAChB,uBAAmB;AA+O1B,OAAO,MAAMC,yBACXH,4BACE,wBACA;IAAEI,UAAU;AAAM,GACnB;AAEH,OAAO,MAAMC,yBACXL,4BACE,gCACA;IAAEI,UAAU;AAAK,GAClB;AAEH;;;;;CAKC,GACD,OAAO,SAASE,mCACdC,MAAkC,EAClCC,OAA+B;IAE/BP,sBAAsBE,wBAAwBI,QAAQ,aAChDC,CAAAA,2BAAAA,QAASC,QAAQ,IAAG;QAAEA,UAAUD,QAAQC,QAAQ;IAAC,IAAI,CAAC;AAE9D;AAEA,4DAA4D,GAC5D,OAAO,SAASC,4BACdC,WAA2C,EAC3CH,OAA+B;cAObT,OAAAA;QALNY,qBAEPA;IAFL,MAAMC,eAAMD,sBAAAA,YAAYE,MAAM,qBAAlBF,oBAAoBG,IAAI,qBAAM;IAC1C,IAAI,CAACF,KAAK,MAAM,IAAIG,MAAM;IAC1B,IAAI,GAACJ,qBAAAA,YAAYK,KAAK,qBAAjBL,mBAAmBG,IAAI,KAAI;QAC9B,MAAM,IAAIC,MAAM,CAAC,wBAAwB,EAAEH,IAAI,eAAe,CAAC;IACjE;IACA,MAAMH,WAAW,EAACV,SAAAA,0BAAAA,oCAAAA,0BAA4BS,2BAAAA,QAASC,QAAQ,YAA7CV,QAAiD,IAAIe,IAAI;IAC3E,MAAMG,YAAYf,sBAAsBG,wBAAwBa,IAAI,CAClE,CAACC,QAAUA,MAAMP,GAAG,KAAKA;IAE3B,IAAIK,aAAaR,YAAYQ,UAAUR,QAAQ,KAAKA,UAAU;QAC5D,MAAM,IAAIM,MACR,CAAC,wBAAwB,EAAEH,IAAI,yBAAyB,CAAC,GACvD,CAAC,CAAC,EAAEK,UAAUR,QAAQ,CAAC,YAAY,EAAEA,SAAS,CAAC,CAAC;IAEtD;IACAR,sBACEI,wBACA,aAAKM;QAAaE,QAAQD;QAC1B,aACMJ,CAAAA,2BAAAA,QAASC,QAAQ,IAAG;QAAEA,UAAUD,QAAQC,QAAQ;IAAC,IAAI,CAAC;QAC1DG;;AAGN;AAQA,2EAA2E,GAC3E,OAAO,SAASQ;IACd,MAAMD,QAAQjB,sBAAsBC,uBAAuB,CAAC,EAAE;IAC9D,OAAOgB,QAAQ;QAAEV,UAAUU,MAAMV,QAAQ;QAAEF,QAAQY,MAAME,IAAI;IAAC,IAAI;AACpE;AAEA;;;;;;;;CAQC,GACD,OAAO,eAAeC,qBACpBC,OAAoC;IAEpC,MAAMC,QAAQJ;IACd,IAAI,CAACI,OAAO,OAAO;IACnB,OAAOA,MAAMjB,MAAM,CAACkB,OAAO,CAACF;AAC9B;AAEA,uEAAuE,GACvE,OAAO,SAASG;IACd,OAAOxB,sBAAsBG,wBAAwBsB,GAAG,CAAC,CAACR,QAAW,aAChEA,MAAME,IAAI;YACbZ,UAAUU,MAAMV,QAAQ;;AAE5B;AAEA,6DAA6D,GAC7D,OAAO,SAASmB,oBACdf,MAAc;IAEd,MAAMD,MAAMC,OAAOC,IAAI;IACvB,MAAMK,QAAQjB,sBAAsBG,wBAAwBa,IAAI,CAC9D,CAACW,MAAQA,IAAIjB,GAAG,KAAKA;IAEvB,OAAOO,QAAQ,aAAKA,MAAME,IAAI;QAAEZ,UAAUU,MAAMV,QAAQ;SAAK;AAC/D"}
@@ -107,6 +107,28 @@ export interface PluginEventPayloads {
107
107
  orgId: string;
108
108
  defaultType: string | null;
109
109
  };
110
+ /**
111
+ * Records were REMOVED from a collection, by a path that is not the
112
+ * owning plugin's own (AGL-3330): a deletion through the public API, a
113
+ * person's erasure.
114
+ *
115
+ * Raised after the delete has landed, with each removed document's data
116
+ * as it stood, so a plugin that keeps a figure over those rows — a counter
117
+ * no increment can take back — can recount what they fed. Core names the
118
+ * collection and takes no view of what it holds.
119
+ *
120
+ * `hostIds` are the sites the records were on: the one site for a site's
121
+ * collection, every site of the organization for an organization row.
122
+ */
123
+ 'host.records.removed': {
124
+ orgId: string | null;
125
+ hostIds: readonly string[];
126
+ collection: string;
127
+ records: ReadonlyArray<{
128
+ id: string;
129
+ data: Readonly<Record<string, unknown>>;
130
+ }>;
131
+ };
110
132
  /** One permission key moved on a subject of the org. */
111
133
  'org.permissions.changed': {
112
134
  orgId: string;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-events.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 * Platform events a plugin listens to (AGL-2939, AGL-2940).\n *\n * Core raises a small number of facts about the workspace that a plugin\n * may care about — a seat add-on was bought or dropped, a permission key\n * moved on a member, a role or a site — and a plugin subscribes from its\n * declarations rather than core calling the plugin's own writer. The\n * billing-webhook hooks are the precedent for one vendor's events; this is\n * the same shape for the platform's own.\n *\n * Handlers run sequentially and are ISOLATED: a plugin that throws is\n * logged and the next one runs, because these are consequences of a write\n * that has already happened — an activity row, a notification — and a\n * failure in one must not undo or hide the write for the customer.\n */\n\nimport type { OrgSeatAddons } from '../foundation'\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** Who did it, as the activity logs record an actor. */\nexport interface PluginEventActor {\n uid: string | null\n email: string | null\n}\n\n/** The payload each platform event carries. */\nexport interface PluginEventPayloads {\n /** An org's purchased seat add-ons were written (a purchase, a removal, a webhook mirror). */\n 'org.seatAddons.changed': {\n orgId: string\n actor: PluginEventActor\n before: OrgSeatAddons | null | undefined\n after: OrgSeatAddons | null | undefined\n }\n /**\n * The workspace's Stripe invoice was paid, in full, by a real charge.\n *\n * Raised from the platform billing webhook once the invoice has resolved\n * to a workspace, so a plugin that bills its own usage learns the outcome\n * of the invoice it asked for without subscribing to Stripe itself. The\n * invoice's own `metadata` travels with it, unread by core: a plugin that\n * stamped `pluginId` on the invoice it created recognizes its own by it,\n * and every other handler ignores the event.\n *\n * `paidOutOfBand` is Stripe's own flag for an invoice marked paid without\n * a charge. A plugin counting payment history must not count one: it is a\n * staff action, not evidence that money moved.\n */\n 'billing.invoice.paid': {\n orgId: string\n invoiceId: string\n amountPaidCents: number\n currency: string\n paidOutOfBand: boolean\n metadata: Readonly<Record<string, string>>\n }\n /** A charge attempt on the workspace's invoice failed or needs action. */\n 'billing.invoice.failed': {\n orgId: string\n invoiceId: string\n amountDueCents: number\n metadata: Readonly<Record<string, string>>\n }\n /**\n * An invoice was closed without being paid — voided, or written off as\n * uncollectible.\n *\n * ONE event for both Stripe events because they are one fact to a plugin:\n * this invoice will never be paid and nothing more is owed on it. A\n * handler that must tell them apart reads `reason`; one that only needs\n * to stop waiting does not.\n */\n 'billing.invoice.closed': {\n orgId: string\n invoiceId: string\n reason: 'voided' | 'uncollectible'\n metadata: Readonly<Record<string, string>>\n }\n /** A cardholder disputed a charge of the workspace's. */\n 'billing.dispute.opened': {\n orgId: string\n chargeId: string\n invoiceId: string | null\n amountCents: number\n }\n /**\n * The workspace's default payment method changed, or its type did.\n *\n * `defaultType` is Stripe's own type string (`card`, `us_bank_account`,\n * `link`, …), or `null` when the customer has no default. Core states the\n * fact and takes no view: which types a plugin will extend credit against\n * is the plugin's decision, not the platform's.\n */\n 'billing.paymentMethod.changed': {\n orgId: string\n defaultType: string | null\n }\n /** One permission key moved on a subject of the org. */\n 'org.permissions.changed': {\n orgId: string\n actor: PluginEventActor\n subject: {\n type: 'org' | 'role' | 'member' | 'host'\n id?: string | null\n name?: string | null\n }\n permission: string\n granted: boolean\n }\n}\n\nexport type PluginEventName = keyof PluginEventPayloads\n\nexport type PluginEventHandler<E extends PluginEventName> = (\n payload: PluginEventPayloads[E],\n) => void | Promise<void>\n\ninterface Registration {\n pluginId: string\n handler: PluginEventHandler<PluginEventName>\n}\n\nconst handlers = new Map<PluginEventName, Registration[]>()\n\n/**\n * Subscribes to an event. Owner = the loader's marker inside a register fn,\n * else `options.pluginId`; a subscription with neither throws. A plugin\n * re-subscribing replaces its earlier handler for that event, so a module\n * evaluated twice does not write two rows.\n */\nexport function registerPluginEventHandler<E extends PluginEventName>(\n event: E,\n handler: PluginEventHandler<E>,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n `plugin event \"${event}\" subscribed with no owner: pass { pluginId } ` +\n 'when subscribing outside a plugin register fn',\n )\n }\n const list = (handlers.get(event) ?? []).filter((entry) => entry.pluginId !== pluginId)\n list.push({ pluginId, handler: handler as PluginEventHandler<PluginEventName> })\n handlers.set(event, list)\n}\n\n/**\n * Raises an event. Every handler runs, in subscription order; a throw is\n * logged with the plugin's id and does not stop the others.\n */\nexport async function runPluginEventHandlers<E extends PluginEventName>(\n event: E,\n payload: PluginEventPayloads[E],\n): Promise<{ handled: number; failed: string[] }> {\n let handled = 0\n const failed: string[] = []\n for (const { pluginId, handler } of handlers.get(event) ?? []) {\n try {\n await handler(payload)\n handled += 1\n } catch (error) {\n failed.push(pluginId)\n console.error(`[plugins] ${pluginId} failed on event ${event}`, error)\n }\n }\n return { handled, failed }\n}\n\n/** The plugins subscribed to an event, for diagnostics. */\nexport function listPluginEventHandlers(event: PluginEventName): string[] {\n return (handlers.get(event) ?? []).map((entry) => entry.pluginId)\n}\n\n/** Test seam: forget every subscription. */\nexport function resetPluginEventHandlersForTests(): void {\n handlers.clear()\n}\n"],"names":["getRegisteringPluginId","handlers","Map","registerPluginEventHandler","event","handler","options","pluginId","trim","Error","list","get","filter","entry","push","set","runPluginEventHandlers","payload","handled","failed","error","console","listPluginEventHandlers","map","resetPluginEventHandlersForTests","clear"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;CAcC,GAGD,SAASA,sBAAsB,QAAQ,qCAAiC;AAyGxE,MAAMC,WAAW,IAAIC;AAErB;;;;;CAKC,GACD,OAAO,SAASC,2BACdC,KAAQ,EACRC,OAA8B,EAC9BC,OAA+B;QAEbN,MAAAA,yBAOJC;IAPd,MAAMM,WAAW,EAACP,QAAAA,0BAAAA,oCAAAA,0BAA4BM,2BAAAA,QAASC,QAAQ,YAA7CP,OAAiD,IAAIQ,IAAI;IAC3E,IAAI,CAACD,UAAU;QACb,MAAM,IAAIE,MACR,CAAC,cAAc,EAAEL,MAAM,8CAA8C,CAAC,GACpE;IAEN;IACA,MAAMM,OAAO,EAACT,gBAAAA,SAASU,GAAG,CAACP,kBAAbH,gBAAuB,EAAE,EAAEW,MAAM,CAAC,CAACC,QAAUA,MAAMN,QAAQ,KAAKA;IAC9EG,KAAKI,IAAI,CAAC;QAAEP;QAAUF,SAASA;IAA+C;IAC9EJ,SAASc,GAAG,CAACX,OAAOM;AACtB;AAEA;;;CAGC,GACD,OAAO,eAAeM,uBACpBZ,KAAQ,EACRa,OAA+B;QAIKhB;IAFpC,IAAIiB,UAAU;IACd,MAAMC,SAAmB,EAAE;IAC3B,KAAK,MAAM,EAAEZ,QAAQ,EAAEF,OAAO,EAAE,KAAIJ,gBAAAA,SAASU,GAAG,CAACP,kBAAbH,gBAAuB,EAAE,CAAE;QAC7D,IAAI;YACF,MAAMI,QAAQY;YACdC,WAAW;QACb,EAAE,OAAOE,OAAO;YACdD,OAAOL,IAAI,CAACP;YACZc,QAAQD,KAAK,CAAC,CAAC,UAAU,EAAEb,SAAS,iBAAiB,EAAEH,OAAO,EAAEgB;QAClE;IACF;IACA,OAAO;QAAEF;QAASC;IAAO;AAC3B;AAEA,yDAAyD,GACzD,OAAO,SAASG,wBAAwBlB,KAAsB;QACpDH;IAAR,OAAO,EAACA,gBAAAA,SAASU,GAAG,CAACP,kBAAbH,gBAAuB,EAAE,EAAEsB,GAAG,CAAC,CAACV,QAAUA,MAAMN,QAAQ;AAClE;AAEA,0CAA0C,GAC1C,OAAO,SAASiB;IACdvB,SAASwB,KAAK;AAChB"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-events.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 * Platform events a plugin listens to (AGL-2939, AGL-2940).\n *\n * Core raises a small number of facts about the workspace that a plugin\n * may care about — a seat add-on was bought or dropped, a permission key\n * moved on a member, a role or a site — and a plugin subscribes from its\n * declarations rather than core calling the plugin's own writer. The\n * billing-webhook hooks are the precedent for one vendor's events; this is\n * the same shape for the platform's own.\n *\n * Handlers run sequentially and are ISOLATED: a plugin that throws is\n * logged and the next one runs, because these are consequences of a write\n * that has already happened — an activity row, a notification — and a\n * failure in one must not undo or hide the write for the customer.\n */\n\nimport type { OrgSeatAddons } from '../foundation'\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** Who did it, as the activity logs record an actor. */\nexport interface PluginEventActor {\n uid: string | null\n email: string | null\n}\n\n/** The payload each platform event carries. */\nexport interface PluginEventPayloads {\n /** An org's purchased seat add-ons were written (a purchase, a removal, a webhook mirror). */\n 'org.seatAddons.changed': {\n orgId: string\n actor: PluginEventActor\n before: OrgSeatAddons | null | undefined\n after: OrgSeatAddons | null | undefined\n }\n /**\n * The workspace's Stripe invoice was paid, in full, by a real charge.\n *\n * Raised from the platform billing webhook once the invoice has resolved\n * to a workspace, so a plugin that bills its own usage learns the outcome\n * of the invoice it asked for without subscribing to Stripe itself. The\n * invoice's own `metadata` travels with it, unread by core: a plugin that\n * stamped `pluginId` on the invoice it created recognizes its own by it,\n * and every other handler ignores the event.\n *\n * `paidOutOfBand` is Stripe's own flag for an invoice marked paid without\n * a charge. A plugin counting payment history must not count one: it is a\n * staff action, not evidence that money moved.\n */\n 'billing.invoice.paid': {\n orgId: string\n invoiceId: string\n amountPaidCents: number\n currency: string\n paidOutOfBand: boolean\n metadata: Readonly<Record<string, string>>\n }\n /** A charge attempt on the workspace's invoice failed or needs action. */\n 'billing.invoice.failed': {\n orgId: string\n invoiceId: string\n amountDueCents: number\n metadata: Readonly<Record<string, string>>\n }\n /**\n * An invoice was closed without being paid — voided, or written off as\n * uncollectible.\n *\n * ONE event for both Stripe events because they are one fact to a plugin:\n * this invoice will never be paid and nothing more is owed on it. A\n * handler that must tell them apart reads `reason`; one that only needs\n * to stop waiting does not.\n */\n 'billing.invoice.closed': {\n orgId: string\n invoiceId: string\n reason: 'voided' | 'uncollectible'\n metadata: Readonly<Record<string, string>>\n }\n /** A cardholder disputed a charge of the workspace's. */\n 'billing.dispute.opened': {\n orgId: string\n chargeId: string\n invoiceId: string | null\n amountCents: number\n }\n /**\n * The workspace's default payment method changed, or its type did.\n *\n * `defaultType` is Stripe's own type string (`card`, `us_bank_account`,\n * `link`, …), or `null` when the customer has no default. Core states the\n * fact and takes no view: which types a plugin will extend credit against\n * is the plugin's decision, not the platform's.\n */\n 'billing.paymentMethod.changed': {\n orgId: string\n defaultType: string | null\n }\n /**\n * Records were REMOVED from a collection, by a path that is not the\n * owning plugin's own (AGL-3330): a deletion through the public API, a\n * person's erasure.\n *\n * Raised after the delete has landed, with each removed document's data\n * as it stood, so a plugin that keeps a figure over those rows — a counter\n * no increment can take back — can recount what they fed. Core names the\n * collection and takes no view of what it holds.\n *\n * `hostIds` are the sites the records were on: the one site for a site's\n * collection, every site of the organization for an organization row.\n */\n 'host.records.removed': {\n orgId: string | null\n hostIds: readonly string[]\n collection: string\n records: ReadonlyArray<{ id: string; data: Readonly<Record<string, unknown>> }>\n }\n /** One permission key moved on a subject of the org. */\n 'org.permissions.changed': {\n orgId: string\n actor: PluginEventActor\n subject: {\n type: 'org' | 'role' | 'member' | 'host'\n id?: string | null\n name?: string | null\n }\n permission: string\n granted: boolean\n }\n}\n\nexport type PluginEventName = keyof PluginEventPayloads\n\nexport type PluginEventHandler<E extends PluginEventName> = (\n payload: PluginEventPayloads[E],\n) => void | Promise<void>\n\ninterface Registration {\n pluginId: string\n handler: PluginEventHandler<PluginEventName>\n}\n\nconst handlers = new Map<PluginEventName, Registration[]>()\n\n/**\n * Subscribes to an event. Owner = the loader's marker inside a register fn,\n * else `options.pluginId`; a subscription with neither throws. A plugin\n * re-subscribing replaces its earlier handler for that event, so a module\n * evaluated twice does not write two rows.\n */\nexport function registerPluginEventHandler<E extends PluginEventName>(\n event: E,\n handler: PluginEventHandler<E>,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n `plugin event \"${event}\" subscribed with no owner: pass { pluginId } ` +\n 'when subscribing outside a plugin register fn',\n )\n }\n const list = (handlers.get(event) ?? []).filter((entry) => entry.pluginId !== pluginId)\n list.push({ pluginId, handler: handler as PluginEventHandler<PluginEventName> })\n handlers.set(event, list)\n}\n\n/**\n * Raises an event. Every handler runs, in subscription order; a throw is\n * logged with the plugin's id and does not stop the others.\n */\nexport async function runPluginEventHandlers<E extends PluginEventName>(\n event: E,\n payload: PluginEventPayloads[E],\n): Promise<{ handled: number; failed: string[] }> {\n let handled = 0\n const failed: string[] = []\n for (const { pluginId, handler } of handlers.get(event) ?? []) {\n try {\n await handler(payload)\n handled += 1\n } catch (error) {\n failed.push(pluginId)\n console.error(`[plugins] ${pluginId} failed on event ${event}`, error)\n }\n }\n return { handled, failed }\n}\n\n/** The plugins subscribed to an event, for diagnostics. */\nexport function listPluginEventHandlers(event: PluginEventName): string[] {\n return (handlers.get(event) ?? []).map((entry) => entry.pluginId)\n}\n\n/** Test seam: forget every subscription. */\nexport function resetPluginEventHandlersForTests(): void {\n handlers.clear()\n}\n"],"names":["getRegisteringPluginId","handlers","Map","registerPluginEventHandler","event","handler","options","pluginId","trim","Error","list","get","filter","entry","push","set","runPluginEventHandlers","payload","handled","failed","error","console","listPluginEventHandlers","map","resetPluginEventHandlersForTests","clear"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;CAcC,GAGD,SAASA,sBAAsB,QAAQ,qCAAiC;AA4HxE,MAAMC,WAAW,IAAIC;AAErB;;;;;CAKC,GACD,OAAO,SAASC,2BACdC,KAAQ,EACRC,OAA8B,EAC9BC,OAA+B;QAEbN,MAAAA,yBAOJC;IAPd,MAAMM,WAAW,EAACP,QAAAA,0BAAAA,oCAAAA,0BAA4BM,2BAAAA,QAASC,QAAQ,YAA7CP,OAAiD,IAAIQ,IAAI;IAC3E,IAAI,CAACD,UAAU;QACb,MAAM,IAAIE,MACR,CAAC,cAAc,EAAEL,MAAM,8CAA8C,CAAC,GACpE;IAEN;IACA,MAAMM,OAAO,EAACT,gBAAAA,SAASU,GAAG,CAACP,kBAAbH,gBAAuB,EAAE,EAAEW,MAAM,CAAC,CAACC,QAAUA,MAAMN,QAAQ,KAAKA;IAC9EG,KAAKI,IAAI,CAAC;QAAEP;QAAUF,SAASA;IAA+C;IAC9EJ,SAASc,GAAG,CAACX,OAAOM;AACtB;AAEA;;;CAGC,GACD,OAAO,eAAeM,uBACpBZ,KAAQ,EACRa,OAA+B;QAIKhB;IAFpC,IAAIiB,UAAU;IACd,MAAMC,SAAmB,EAAE;IAC3B,KAAK,MAAM,EAAEZ,QAAQ,EAAEF,OAAO,EAAE,KAAIJ,gBAAAA,SAASU,GAAG,CAACP,kBAAbH,gBAAuB,EAAE,CAAE;QAC7D,IAAI;YACF,MAAMI,QAAQY;YACdC,WAAW;QACb,EAAE,OAAOE,OAAO;YACdD,OAAOL,IAAI,CAACP;YACZc,QAAQD,KAAK,CAAC,CAAC,UAAU,EAAEb,SAAS,iBAAiB,EAAEH,OAAO,EAAEgB;QAClE;IACF;IACA,OAAO;QAAEF;QAASC;IAAO;AAC3B;AAEA,yDAAyD,GACzD,OAAO,SAASG,wBAAwBlB,KAAsB;QACpDH;IAAR,OAAO,EAACA,gBAAAA,SAASU,GAAG,CAACP,kBAAbH,gBAAuB,EAAE,EAAEsB,GAAG,CAAC,CAACV,QAAUA,MAAMN,QAAQ;AAClE;AAEA,0CAA0C,GAC1C,OAAO,SAASiB;IACdvB,SAASwB,KAAK;AAChB"}
@@ -61,10 +61,14 @@ export interface PluginJobHostGate {
61
61
  isLocked(hostId: string): Promise<boolean>;
62
62
  }
63
63
  /**
64
- * The gate as the runner builds it. Exported because the MANUAL doors —
65
- * `commerce/process-abandoned`, `bookings/reminders` and the other
66
- * `x-cron-secret` entry points that still exist for ops — call the same scan
67
- * functions and must ask the same question. A forced pass is still a pass.
64
+ * The gate as the runner builds it.
65
+ *
66
+ * The MANUAL doors — `commerce/process-abandoned`, `commerce/process-restock`,
67
+ * `bookings/reminders` — do NOT use it (AGL-3356). They are served by the
68
+ * console, which registers no resolver, so this answered "not locked" for
69
+ * every site there and a forced pass mailed a suspended site's customers.
70
+ * They take `siteLockdownJobGate()` from the admin lib instead: the same
71
+ * function the tenant registers here, needing no registration at all.
68
72
  *
69
73
  * NO resolver registered answers "not locked", loudly and once. That is the
70
74
  * fail-open direction on purpose: a deployment that has not wired a carrier
@@ -85,10 +85,14 @@
85
85
  hostLockedResolver = null;
86
86
  }
87
87
  /**
88
- * The gate as the runner builds it. Exported because the MANUAL doors —
89
- * `commerce/process-abandoned`, `bookings/reminders` and the other
90
- * `x-cron-secret` entry points that still exist for ops — call the same scan
91
- * functions and must ask the same question. A forced pass is still a pass.
88
+ * The gate as the runner builds it.
89
+ *
90
+ * The MANUAL doors — `commerce/process-abandoned`, `commerce/process-restock`,
91
+ * `bookings/reminders` — do NOT use it (AGL-3356). They are served by the
92
+ * console, which registers no resolver, so this answered "not locked" for
93
+ * every site there and a forced pass mailed a suspended site's customers.
94
+ * They take `siteLockdownJobGate()` from the admin lib instead: the same
95
+ * function the tenant registers here, needing no registration at all.
92
96
  *
93
97
  * NO resolver registered answers "not locked", loudly and once. That is the
94
98
  * fail-open direction on purpose: a deployment that has not wired a carrier