@aglyn/plugins-outreach 1.0.0-beta.220 → 1.0.0-beta.222

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 (116) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/do-not-contact-domains.d.ts +6 -2
  3. package/src/lib/components/do-not-contact-domains.js +73 -11
  4. package/src/lib/components/do-not-contact-domains.js.map +1 -1
  5. package/src/lib/components/enrollments-table.d.ts +6 -0
  6. package/src/lib/components/enrollments-table.js +77 -4
  7. package/src/lib/components/enrollments-table.js.map +1 -1
  8. package/src/lib/components/sequence-detail.js +3 -1
  9. package/src/lib/components/sequence-detail.js.map +1 -1
  10. package/src/lib/components/sequence-report-card.js +7 -3
  11. package/src/lib/components/sequence-report-card.js.map +1 -1
  12. package/src/lib/components/use-outreach-crm.d.ts +18 -15
  13. package/src/lib/components/use-outreach-crm.js +118 -87
  14. package/src/lib/components/use-outreach-crm.js.map +1 -1
  15. package/src/lib/components/use-outreach-data.d.ts +2 -2
  16. package/src/lib/components/use-outreach-data.js.map +1 -1
  17. package/src/lib/constants/transfer-resources.d.ts +33 -0
  18. package/src/lib/constants/transfer-resources.js +38 -0
  19. package/src/lib/constants/transfer-resources.js.map +1 -0
  20. package/src/lib/declarations.console-server.d.ts +7 -1
  21. package/src/lib/declarations.console-server.js +66 -1
  22. package/src/lib/declarations.console-server.js.map +1 -1
  23. package/src/lib/engine/click-tracking.js +1 -1
  24. package/src/lib/engine/click-tracking.js.map +1 -1
  25. package/src/lib/engine/ip-range.d.ts +26 -0
  26. package/src/lib/engine/ip-range.js +78 -0
  27. package/src/lib/engine/ip-range.js.map +1 -0
  28. package/src/lib/engine/open-source.d.ts +24 -0
  29. package/src/lib/engine/open-source.js +130 -0
  30. package/src/lib/engine/open-source.js.map +1 -0
  31. package/src/lib/engine/open-tracking.d.ts +15 -2
  32. package/src/lib/engine/open-tracking.js +59 -16
  33. package/src/lib/engine/open-tracking.js.map +1 -1
  34. package/src/lib/engine/outreach-access.d.ts +47 -0
  35. package/src/lib/engine/outreach-access.js +56 -0
  36. package/src/lib/engine/outreach-access.js.map +1 -0
  37. package/src/lib/enrollment/enroll-people.d.ts +10 -10
  38. package/src/lib/enrollment/enroll-people.js +30 -25
  39. package/src/lib/enrollment/enroll-people.js.map +1 -1
  40. package/src/lib/enrollment/gate-lookups.js +30 -15
  41. package/src/lib/enrollment/gate-lookups.js.map +1 -1
  42. package/src/lib/enrollment/message-templates.d.ts +24 -0
  43. package/src/lib/enrollment/message-templates.js +35 -0
  44. package/src/lib/enrollment/message-templates.js.map +1 -0
  45. package/src/lib/model/enrollment-engagement.d.ts +16 -0
  46. package/src/lib/model/enrollment-engagement.js +16 -0
  47. package/src/lib/model/enrollment-engagement.js.map +1 -1
  48. package/src/lib/model/enrollment-history.d.ts +8 -1
  49. package/src/lib/model/enrollment-history.js +17 -5
  50. package/src/lib/model/enrollment-history.js.map +1 -1
  51. package/src/lib/model/enrollment-timeline.d.ts +3 -0
  52. package/src/lib/model/enrollment-timeline.js +15 -4
  53. package/src/lib/model/enrollment-timeline.js.map +1 -1
  54. package/src/lib/model/outreach.types.d.ts +12 -3
  55. package/src/lib/model/outreach.types.js +12 -1
  56. package/src/lib/model/outreach.types.js.map +1 -1
  57. package/src/lib/model/sequence-report.d.ts +14 -7
  58. package/src/lib/model/sequence-report.js +12 -8
  59. package/src/lib/model/sequence-report.js.map +1 -1
  60. package/src/lib/plugin.js +18 -0
  61. package/src/lib/plugin.js.map +1 -1
  62. package/src/lib/routes/curate-routes.js +4 -6
  63. package/src/lib/routes/curate-routes.js.map +1 -1
  64. package/src/lib/routes/enroll-routes.d.ts +0 -12
  65. package/src/lib/routes/enroll-routes.js +52 -92
  66. package/src/lib/routes/enroll-routes.js.map +1 -1
  67. package/src/lib/routes/platform-deps.d.ts +4 -16
  68. package/src/lib/routes/platform-deps.js +5 -25
  69. package/src/lib/routes/platform-deps.js.map +1 -1
  70. package/src/lib/routes/preview-routes.js +25 -13
  71. package/src/lib/routes/preview-routes.js.map +1 -1
  72. package/src/lib/routes/register-routes.js +7 -5
  73. package/src/lib/routes/register-routes.js.map +1 -1
  74. package/src/lib/routes/route-gate.js +6 -17
  75. package/src/lib/routes/route-gate.js.map +1 -1
  76. package/src/lib/routes/sequence-routes.d.ts +46 -0
  77. package/src/lib/routes/sequence-routes.js +155 -63
  78. package/src/lib/routes/sequence-routes.js.map +1 -1
  79. package/src/lib/runtime/click-events.js +8 -8
  80. package/src/lib/runtime/click-events.js.map +1 -1
  81. package/src/lib/runtime/click-route.js +5 -2
  82. package/src/lib/runtime/click-route.js.map +1 -1
  83. package/src/lib/runtime/lead-records.d.ts +18 -10
  84. package/src/lib/runtime/lead-records.js +14 -60
  85. package/src/lib/runtime/lead-records.js.map +1 -1
  86. package/src/lib/runtime/open-events.d.ts +8 -0
  87. package/src/lib/runtime/open-events.js +10 -3
  88. package/src/lib/runtime/open-events.js.map +1 -1
  89. package/src/lib/runtime/platform-runtime-deps.d.ts +9 -5
  90. package/src/lib/runtime/platform-runtime-deps.js +31 -24
  91. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  92. package/src/lib/runtime/send-job.js +20 -17
  93. package/src/lib/runtime/send-job.js.map +1 -1
  94. package/src/lib/runtime/sync-job.js +10 -5
  95. package/src/lib/runtime/sync-job.js.map +1 -1
  96. package/src/lib/storage/do-not-contact-store.d.ts +8 -0
  97. package/src/lib/storage/do-not-contact-store.js +13 -0
  98. package/src/lib/storage/do-not-contact-store.js.map +1 -1
  99. package/src/lib/testing/stand-in-record-system.d.ts +54 -0
  100. package/src/lib/testing/stand-in-record-system.js +243 -0
  101. package/src/lib/testing/stand-in-record-system.js.map +1 -0
  102. package/src/lib/transfer/do-not-contact-transfer.d.ts +129 -0
  103. package/src/lib/transfer/do-not-contact-transfer.js +801 -0
  104. package/src/lib/transfer/do-not-contact-transfer.js.map +1 -0
  105. package/src/lib/transfer/plan-gate.d.ts +25 -0
  106. package/src/lib/transfer/plan-gate.js +35 -0
  107. package/src/lib/transfer/plan-gate.js.map +1 -0
  108. package/src/lib/transfer/platform-transfer-deps.d.ts +24 -0
  109. package/src/lib/transfer/platform-transfer-deps.js +39 -0
  110. package/src/lib/transfer/platform-transfer-deps.js.map +1 -0
  111. package/src/lib/transfer/sequences-package.d.ts +48 -0
  112. package/src/lib/transfer/sequences-package.js +93 -0
  113. package/src/lib/transfer/sequences-package.js.map +1 -0
  114. package/src/lib/transfer/sequences-package.server.d.ts +31 -0
  115. package/src/lib/transfer/sequences-package.server.js +207 -0
  116. package/src/lib/transfer/sequences-package.server.js.map +1 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/register-routes.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 { pluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport { pluginTextGenerator } from '@aglyn/aglyn/plugin-manager/plugin-text-generation'\nimport { registerPluginApiRoute } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { platformMailDnsResolver } from '@aglyn/tenant-data-admin/server/email-deliverability'\nimport { OUTREACH_API_ROUTES } from '../constants/api-routes'\nimport { openOutreachMailboxClient } from '../mailboxes/mailbox-transport'\nimport { outreachOrgSubject } from '../mailboxes/register-mailbox-routes'\nimport { outreachShortLinkUrl } from '../runtime/click-link'\nimport { createOutreachCurateRoutes } from './curate-routes'\nimport { createOutreachDoNotContactDomainsRoute } from './do-not-contact-routes'\nimport { createOutreachEnrollRoutes, type OutreachEnrollRouteDeps } from './enroll-routes'\nimport {\n createOutreachEnrollmentActionRoute,\n createOutreachEnrollmentGatewayRoute,\n} from './enrollment-routes'\nimport { canonicalConsoleOrigin } from '../mailboxes/oauth-redirect'\nimport { createOutreachLinkDomainsRoute, type OutreachLinkDomainRouteDeps } from './link-domain-routes'\nimport { createOutreachPreviewRoute } from './preview-routes'\nimport type { OutreachRouteGateDeps } from './route-gate'\nimport { createOutreachSequenceRoutes } from './sequence-routes'\nimport { createOutreachSettingsRoute } from './settings-routes'\nimport { createOutreachStepTestRoute, type OutreachStepTestDeps } from './step-test-routes'\n\n/**\n * Wires the settings, sequence and enrollment routes into the console\n * dispatcher (AGL-2980) with the platform's own dependencies. Specs build\n * their own.\n *\n * Every one names its organization (`orgId`, in the query or the JSON body)\n * and none names a site, so each declares the mailbox routes' subject reader:\n * the dispatcher's release gate then asks about that organization, and a\n * `release_outreach` override for one organization reaches these routes as it\n * reaches Mailboxes. Without it the gate reads the request as anonymous, which\n * only a fully enabled flag or a staff session passes.\n */\n\n/** The platform's heavier modules, loaded the first time a request needs one. */\nconst platform = () => import('./platform-deps')\n\n/** The gate's reach into the platform. */\nexport function defaultOutreachRouteGateDeps(): OutreachRouteGateDeps {\n return {\n verifyIdToken: (idToken) => firebaseAdmin.app().auth().verifyIdToken(idToken),\n resolveOrgPermissions: async (uid, context) => (await platform()).resolveOrgPermissions(uid, context),\n holdsOrgCatalogPermission: async (uid, orgId, key) =>\n (await platform()).holdsOrgCatalogPermission(uid, orgId, key),\n readOrg: async (orgId) => {\n const snapshot = await firebaseAdmin.app().firestore().collection('orgs').doc(orgId).get()\n return snapshot.exists ? (snapshot.data() ?? {}) : null\n },\n lockdownRefusal: async (options) => (await platform()).lockdownRefusal(options),\n }\n}\n\nexport function defaultOutreachRouteDeps(): OutreachEnrollRouteDeps {\n return {\n firestore: () => firebaseAdmin.app().firestore(),\n gate: defaultOutreachRouteGateDeps(),\n now: Date.now,\n random: Math.random,\n logOrgActivity: async (orgId, actor, action, target) =>\n (await platform()).logOrgActivity(orgId, actor, action, target),\n crmViewEmails: async (input) => (await platform()).crmViewEmails(input),\n stampRecordEmailState: async (stamp) => {\n await (await platform()).stampRecordEmailState(stamp)\n },\n creditCampaign: async ({ hostId, orgId, campaignIds, outcome, atMs }) => {\n await (await platform()).creditCampaignSequenceOutcome(\n { hostId, orgId, campaignIds, outcome, atMs },\n firebaseAdmin.app().firestore(),\n )\n },\n // The record system's writer (AGL-3274), resolved per call the way the\n // runtime resolves it: the CRM registers at boot, and a request that\n // arrives before it did files nothing rather than caching a `null`.\n timeline: () => pluginRecordTimelineWriter()?.writer ?? null,\n // The domain's MX (AGL-3326) and, for a domain with none, its address\n // record — through the platform's pinned resolver (AGL-3328), which\n // asks twice before it says a domain takes no mail.\n resolveMx: (domain) => platformMailDnsResolver().resolveMx(domain),\n resolveAddress: (domain) => platformMailDnsResolver().resolveAddress?.(domain) ?? Promise.resolve(false),\n // The workspace's text generator (AGL-3324), resolved per call the same\n // way: the AI plugin registers at boot, and a request before it did is\n // told drafting is unavailable rather than caching a `null`.\n textGenerator: () => pluginTextGenerator()?.generator ?? null,\n }\n}\n\n/** The link domains route's reach (AGL-3306): the tracking-host store, loaded on first use. */\nexport function defaultOutreachLinkDomainDeps(): OutreachLinkDomainRouteDeps {\n return {\n hosts: async () => {\n const loaded = await platform()\n return {\n list: loaded.listTrackingHosts,\n setUp: loaded.setUpTrackingHost,\n verify: loaded.verifyTrackingHost,\n remove: loaded.removeTrackingHost,\n }\n },\n consoleOrigin: canonicalConsoleOrigin,\n }\n}\n\n/**\n * The test send's reach (AGL-3325): the mailbox's Gmail through the same\n * door the runtime and the Mailboxes test use, the platform's rate limit,\n * and the short links a real send would mint.\n */\nexport function defaultOutreachStepTestDeps(): OutreachStepTestDeps {\n return {\n openMailbox: (mailboxId) => openOutreachMailboxClient(firebaseAdmin.app().firestore(), { mailboxId }),\n consumeRateLimit: async (key, options) =>\n (await import('@aglyn/tenant-data-admin/server/rate-limit-store')).consumeRateLimit(key, options),\n clickLinkUrl: (linkId, trackingOrigin) =>\n outreachShortLinkUrl({ origin: canonicalConsoleOrigin(), linkId, trackingOrigin }),\n clickLinkOrigin: async ({ orgId, senderAddress }) =>\n (await platform()).resolveTrackingLinkOrigin(firebaseAdmin.app().firestore(), orgId, senderAddress),\n }\n}\n\nexport function registerOutreachRoutes(\n deps: OutreachEnrollRouteDeps = defaultOutreachRouteDeps(),\n): void {\n const sequences = createOutreachSequenceRoutes(deps)\n const enroll = createOutreachEnrollRoutes(deps)\n const curate = createOutreachCurateRoutes(deps)\n const orgSubject = { subject: outreachOrgSubject }\n registerPluginApiRoute(OUTREACH_API_ROUTES.settings, { web: createOutreachSettingsRoute(deps) }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesSave, { web: sequences.save }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesStatus, { web: sequences.status }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesDelete, { web: sequences.remove }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.enrollPreview, { web: enroll.preview }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.enroll, { web: enroll.confirm }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.enrollmentsAction,\n { web: createOutreachEnrollmentActionRoute(deps) },\n orgSubject,\n )\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.enrollmentsGateway,\n { web: createOutreachEnrollmentGatewayRoute(deps) },\n orgSubject,\n )\n registerPluginApiRoute(OUTREACH_API_ROUTES.preview, { web: createOutreachPreviewRoute(deps) }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.curateDraft, { web: curate.draft }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.curateSave, { web: curate.save }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.stepTest,\n { web: createOutreachStepTestRoute(deps, defaultOutreachStepTestDeps()) },\n orgSubject,\n )\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.doNotContactDomains,\n { web: createOutreachDoNotContactDomainsRoute(deps) },\n orgSubject,\n )\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.linkDomains,\n { web: createOutreachLinkDomainsRoute(deps, defaultOutreachLinkDomainDeps()) },\n orgSubject,\n )\n}\n"],"names":["pluginRecordTimelineWriter","pluginTextGenerator","registerPluginApiRoute","firebaseAdmin","platformMailDnsResolver","OUTREACH_API_ROUTES","openOutreachMailboxClient","outreachOrgSubject","outreachShortLinkUrl","createOutreachCurateRoutes","createOutreachDoNotContactDomainsRoute","createOutreachEnrollRoutes","createOutreachEnrollmentActionRoute","createOutreachEnrollmentGatewayRoute","canonicalConsoleOrigin","createOutreachLinkDomainsRoute","createOutreachPreviewRoute","createOutreachSequenceRoutes","createOutreachSettingsRoute","createOutreachStepTestRoute","platform","defaultOutreachRouteGateDeps","verifyIdToken","idToken","app","auth","resolveOrgPermissions","uid","context","holdsOrgCatalogPermission","orgId","key","readOrg","snapshot","firestore","collection","doc","get","exists","data","lockdownRefusal","options","defaultOutreachRouteDeps","gate","now","Date","random","Math","logOrgActivity","actor","action","target","crmViewEmails","input","stampRecordEmailState","stamp","creditCampaign","hostId","campaignIds","outcome","atMs","creditCampaignSequenceOutcome","timeline","writer","resolveMx","domain","resolveAddress","Promise","resolve","textGenerator","generator","defaultOutreachLinkDomainDeps","hosts","loaded","list","listTrackingHosts","setUp","setUpTrackingHost","verify","verifyTrackingHost","remove","removeTrackingHost","consoleOrigin","defaultOutreachStepTestDeps","openMailbox","mailboxId","consumeRateLimit","clickLinkUrl","linkId","trackingOrigin","origin","clickLinkOrigin","senderAddress","resolveTrackingLinkOrigin","registerOutreachRoutes","deps","sequences","enroll","curate","orgSubject","subject","settings","web","sequencesSave","save","sequencesStatus","status","sequencesDelete","enrollPreview","preview","confirm","enrollmentsAction","enrollmentsGateway","curateDraft","draft","curateSave","stepTest","doNotContactDomains","linkDomains"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/register-routes.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 { creditConversionOutcome } from '@aglyn/aglyn/plugin-manager/plugin-conversion-credit'\nimport { pluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport { pluginTextGenerator } from '@aglyn/aglyn/plugin-manager/plugin-text-generation'\nimport { registerPluginApiRoute } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { platformMailDnsResolver } from '@aglyn/tenant-data-admin/server/email-deliverability'\nimport { OUTREACH_API_ROUTES } from '../constants/api-routes'\nimport { openOutreachMailboxClient } from '../mailboxes/mailbox-transport'\nimport { outreachOrgSubject } from '../mailboxes/register-mailbox-routes'\nimport { outreachShortLinkUrl } from '../runtime/click-link'\nimport { createOutreachCurateRoutes } from './curate-routes'\nimport { createOutreachDoNotContactDomainsRoute } from './do-not-contact-routes'\nimport { createOutreachEnrollRoutes, type OutreachEnrollRouteDeps } from './enroll-routes'\nimport {\n createOutreachEnrollmentActionRoute,\n createOutreachEnrollmentGatewayRoute,\n} from './enrollment-routes'\nimport { canonicalConsoleOrigin } from '../mailboxes/oauth-redirect'\nimport { createOutreachLinkDomainsRoute, type OutreachLinkDomainRouteDeps } from './link-domain-routes'\nimport { createOutreachPreviewRoute } from './preview-routes'\nimport type { OutreachRouteGateDeps } from './route-gate'\nimport { createOutreachSequenceRoutes } from './sequence-routes'\nimport { createOutreachSettingsRoute } from './settings-routes'\nimport { createOutreachStepTestRoute, type OutreachStepTestDeps } from './step-test-routes'\n\n/**\n * Wires the settings, sequence and enrollment routes into the console\n * dispatcher (AGL-2980) with the platform's own dependencies. Specs build\n * their own.\n *\n * Every one names its organization (`orgId`, in the query or the JSON body)\n * and none names a site, so each declares the mailbox routes' subject reader:\n * the dispatcher's release gate then asks about that organization, and a\n * `release_outreach` override for one organization reaches these routes as it\n * reaches Mailboxes. Without it the gate reads the request as anonymous, which\n * only a fully enabled flag or a staff session passes.\n */\n\n/** The platform's heavier modules, loaded the first time a request needs one. */\nconst platform = () => import('./platform-deps')\n\n/** The gate's reach into the platform. */\nexport function defaultOutreachRouteGateDeps(): OutreachRouteGateDeps {\n return {\n verifyIdToken: (idToken) => firebaseAdmin.app().auth().verifyIdToken(idToken),\n resolveOrgPermissions: async (uid, context) => (await platform()).resolveOrgPermissions(uid, context),\n holdsOrgCatalogPermission: async (uid, orgId, key) =>\n (await platform()).holdsOrgCatalogPermission(uid, orgId, key),\n readOrg: async (orgId) => {\n const snapshot = await firebaseAdmin.app().firestore().collection('orgs').doc(orgId).get()\n return snapshot.exists ? (snapshot.data() ?? {}) : null\n },\n lockdownRefusal: async (options) => (await platform()).lockdownRefusal(options),\n }\n}\n\nexport function defaultOutreachRouteDeps(): OutreachEnrollRouteDeps {\n return {\n firestore: () => firebaseAdmin.app().firestore(),\n gate: defaultOutreachRouteGateDeps(),\n now: Date.now,\n random: Math.random,\n logOrgActivity: async (orgId, actor, action, target) =>\n (await platform()).logOrgActivity(orgId, actor, action, target),\n stampRecordEmailState: async (stamp) => {\n await (await platform()).stampRecordEmailState(stamp)\n },\n // Through the plugin that credits outcomes (`plugin-conversion-credit`),\n // which never throws and answers nothing where no plugin credits them.\n creditCampaign: async ({ hostId, orgId, campaignIds, outcome, atMs }) => {\n await creditConversionOutcome({ hostId, orgId, containerIds: campaignIds, outcome, atMs })\n },\n // The record system's writer (AGL-3274), resolved per call the way the\n // runtime resolves it: the CRM registers at boot, and a request that\n // arrives before it did files nothing rather than caching a `null`.\n timeline: () => pluginRecordTimelineWriter()?.writer ?? null,\n // The domain's MX (AGL-3326) and, for a domain with none, its address\n // record — through the platform's pinned resolver (AGL-3328), which\n // asks twice before it says a domain takes no mail.\n resolveMx: (domain) => platformMailDnsResolver().resolveMx(domain),\n resolveAddress: (domain) => platformMailDnsResolver().resolveAddress?.(domain) ?? Promise.resolve(false),\n // The workspace's text generator (AGL-3324), resolved per call the same\n // way: the AI plugin registers at boot, and a request before it did is\n // told drafting is unavailable rather than caching a `null`.\n textGenerator: () => pluginTextGenerator()?.generator ?? null,\n }\n}\n\n/** The link domains route's reach (AGL-3306): the tracking-host store, loaded on first use. */\nexport function defaultOutreachLinkDomainDeps(): OutreachLinkDomainRouteDeps {\n return {\n hosts: async () => {\n const loaded = await platform()\n return {\n list: loaded.listTrackingHosts,\n setUp: loaded.setUpTrackingHost,\n verify: loaded.verifyTrackingHost,\n remove: loaded.removeTrackingHost,\n }\n },\n consoleOrigin: canonicalConsoleOrigin,\n }\n}\n\n/**\n * The test send's reach (AGL-3325): the mailbox's Gmail through the same\n * door the runtime and the Mailboxes test use, the platform's rate limit,\n * and the short links a real send would mint.\n */\nexport function defaultOutreachStepTestDeps(): OutreachStepTestDeps {\n return {\n openMailbox: (mailboxId) => openOutreachMailboxClient(firebaseAdmin.app().firestore(), { mailboxId }),\n consumeRateLimit: async (key, options) =>\n (await import('@aglyn/tenant-data-admin/server/rate-limit-store')).consumeRateLimit(key, options),\n clickLinkUrl: (linkId, trackingOrigin) =>\n outreachShortLinkUrl({ origin: canonicalConsoleOrigin(), linkId, trackingOrigin }),\n clickLinkOrigin: async ({ orgId, senderAddress }) =>\n (await platform()).resolveTrackingLinkOrigin(firebaseAdmin.app().firestore(), orgId, senderAddress),\n }\n}\n\nexport function registerOutreachRoutes(\n deps: OutreachEnrollRouteDeps = defaultOutreachRouteDeps(),\n): void {\n const sequences = createOutreachSequenceRoutes(deps)\n const enroll = createOutreachEnrollRoutes(deps)\n const curate = createOutreachCurateRoutes(deps)\n const orgSubject = { subject: outreachOrgSubject }\n registerPluginApiRoute(OUTREACH_API_ROUTES.settings, { web: createOutreachSettingsRoute(deps) }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesSave, { web: sequences.save }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesStatus, { web: sequences.status }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesDelete, { web: sequences.remove }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.enrollPreview, { web: enroll.preview }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.enroll, { web: enroll.confirm }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.enrollmentsAction,\n { web: createOutreachEnrollmentActionRoute(deps) },\n orgSubject,\n )\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.enrollmentsGateway,\n { web: createOutreachEnrollmentGatewayRoute(deps) },\n orgSubject,\n )\n registerPluginApiRoute(OUTREACH_API_ROUTES.preview, { web: createOutreachPreviewRoute(deps) }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.curateDraft, { web: curate.draft }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.curateSave, { web: curate.save }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.stepTest,\n { web: createOutreachStepTestRoute(deps, defaultOutreachStepTestDeps()) },\n orgSubject,\n )\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.doNotContactDomains,\n { web: createOutreachDoNotContactDomainsRoute(deps) },\n orgSubject,\n )\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.linkDomains,\n { web: createOutreachLinkDomainsRoute(deps, defaultOutreachLinkDomainDeps()) },\n orgSubject,\n )\n}\n"],"names":["creditConversionOutcome","pluginRecordTimelineWriter","pluginTextGenerator","registerPluginApiRoute","firebaseAdmin","platformMailDnsResolver","OUTREACH_API_ROUTES","openOutreachMailboxClient","outreachOrgSubject","outreachShortLinkUrl","createOutreachCurateRoutes","createOutreachDoNotContactDomainsRoute","createOutreachEnrollRoutes","createOutreachEnrollmentActionRoute","createOutreachEnrollmentGatewayRoute","canonicalConsoleOrigin","createOutreachLinkDomainsRoute","createOutreachPreviewRoute","createOutreachSequenceRoutes","createOutreachSettingsRoute","createOutreachStepTestRoute","platform","defaultOutreachRouteGateDeps","verifyIdToken","idToken","app","auth","resolveOrgPermissions","uid","context","holdsOrgCatalogPermission","orgId","key","readOrg","snapshot","firestore","collection","doc","get","exists","data","lockdownRefusal","options","defaultOutreachRouteDeps","gate","now","Date","random","Math","logOrgActivity","actor","action","target","stampRecordEmailState","stamp","creditCampaign","hostId","campaignIds","outcome","atMs","containerIds","timeline","writer","resolveMx","domain","resolveAddress","Promise","resolve","textGenerator","generator","defaultOutreachLinkDomainDeps","hosts","loaded","list","listTrackingHosts","setUp","setUpTrackingHost","verify","verifyTrackingHost","remove","removeTrackingHost","consoleOrigin","defaultOutreachStepTestDeps","openMailbox","mailboxId","consumeRateLimit","clickLinkUrl","linkId","trackingOrigin","origin","clickLinkOrigin","senderAddress","resolveTrackingLinkOrigin","registerOutreachRoutes","deps","sequences","enroll","curate","orgSubject","subject","settings","web","sequencesSave","save","sequencesStatus","status","sequencesDelete","enrollPreview","preview","confirm","enrollmentsAction","enrollmentsGateway","curateDraft","draft","curateSave","stepTest","doNotContactDomains","linkDomains"],"mappings":"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"}
@@ -13,10 +13,9 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ import { checkEntitlement } from "@aglyn/aglyn/server";
17
- import { isEmailVerified, isImpersonationSession } from "@aglyn/tenant-data-admin/server/firebase-admin";
16
+ */ import { isEmailVerified, isImpersonationSession } from "@aglyn/tenant-data-admin/server/firebase-admin";
18
17
  import { invalidIdTokenResponse } from "@aglyn/tenant-data-admin/server/id-token-refusal";
19
- import { OUTREACH_USE_PERMISSION } from "../constants/bundle-common.js";
18
+ import { outreachEntitlementRefusal, outreachMembershipRefusal } from "../engine/outreach-access.js";
20
19
  import { outreachRefusal, readOutreachDocumentId } from "./route-http.js";
21
20
  function bearer(request) {
22
21
  var _request_headers_get;
@@ -46,26 +45,16 @@ export async function outreachRouteGate(request, rawOrgId, deps, extra = []) {
46
45
  const membership = await deps.resolveOrgPermissions(decoded.uid, {
47
46
  orgId
48
47
  });
49
- // A targeted org the account is not on answers with that org's id and no
50
- // role — as does a lookup that failed closed — so the role is the test.
51
- if (!membership.role || membership.orgId !== orgId) {
52
- return outreachRefusal(403, 'not-a-member', 'You are not a member of that organization.');
53
- }
54
- if (!membership.orgWide) {
55
- return outreachRefusal(403, 'not-org-wide', 'Sequences covers the whole organization, and your access is to particular sites.');
56
- }
57
- if (membership.permissions[OUTREACH_USE_PERMISSION] !== true) {
58
- return outreachRefusal(403, 'permission', 'Your role does not include Use Sequences.');
59
- }
48
+ const refused = outreachMembershipRefusal(membership, orgId);
49
+ if (refused) return outreachRefusal(refused.status, refused.code, refused.message);
60
50
  for (const permission of extra){
61
51
  if (!await deps.holdsOrgCatalogPermission(decoded.uid, orgId, permission.key)) {
62
52
  return outreachRefusal(403, 'permission', permission.refusal);
63
53
  }
64
54
  }
65
55
  const org = (_ref = await deps.readOrg(orgId)) != null ? _ref : {};
66
- if (!checkEntitlement(org, 'outreach')) {
67
- return outreachRefusal(403, 'entitlement', "Sequences isn't available to this workspace yet.");
68
- }
56
+ const unentitled = outreachEntitlementRefusal(org);
57
+ if (unentitled) return outreachRefusal(unentitled.status, unentitled.code, unentitled.message);
69
58
  const staff = decoded['staff'] === true;
70
59
  const locked = await deps.lockdownRefusal({
71
60
  request,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/route-gate.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 { checkEntitlement } from '@aglyn/aglyn/server'\nimport {\n isEmailVerified,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport type { DecodedIdToken } from 'firebase-admin/auth'\nimport { OUTREACH_USE_PERMISSION } from '../constants/bundle-common'\nimport { outreachRefusal, readOutreachDocumentId } from './route-http'\n\n/**\n * THE GATE EVERY SETTINGS, SEQUENCE AND ENROLLMENT ROUTE CLIMBS (AGL-2980).\n *\n * The console dispatcher has already refused a request while\n * `release_outreach` is off for it, an unverified account's token, a\n * lockdown with no org in scope, and a caller over the console rate limit —\n * every route here is registered under the `outreach/` prefix, which is what\n * puts it behind those. What the dispatcher cannot know is anything about\n * the member, so each rung below is this plugin's:\n *\n * 401 no bearer token, or one the verifier refused\n * 403 the account's address is unverified (an impersonation session is exempt)\n * 400 the request did not name an organization\n * 403 not a member of it, a site collaborator without org-wide reach, or\n * without `outreach.use` — or without a permission the route adds\n * 403 the organization does not hold the `outreach` entitlement\n * 423 lockdown, now with the organization and the account in scope\n *\n * The reach is org-wide for the reason the rules give: a sequence enrolls\n * people from every site of the organization, so none of it is a fact\n * about the one site a collaborator was invited to.\n *\n * `resolveOrgPermissions` is always given the concrete org id. Called\n * without one it resolves a caller with no org at all as that future org's\n * owner, which is right for a fresh signup and wrong for every question\n * asked here.\n */\n\nexport interface OutreachRouteGateDeps {\n verifyIdToken(idToken: string): Promise<DecodedIdToken>\n resolveOrgPermissions(\n uid: string,\n context: { orgId: string },\n ): Promise<{\n orgId: string | null\n /** `null` when the account is not on the organization's roster. */\n role: string | null\n isOwner: boolean\n orgWide: boolean\n permissions: Partial<Record<string, boolean | undefined>>\n }>\n /**\n * Whether the member holds a CATALOG permission (`data.manage`) as the\n * org's roles resolve it. Asked separately because the map\n * `resolveOrgPermissions` answers carries the legacy keys and the plugins'\n * own, never a catalog key: read there, a catalog key refuses everyone.\n */\n holdsOrgCatalogPermission(uid: string, orgId: string, key: string): Promise<boolean>\n readOrg(orgId: string): Promise<Record<string, unknown> | null>\n lockdownRefusal(options: {\n request: Request\n staff: boolean\n uid: string\n org: Record<string, unknown>\n }): Promise<Response | null>\n}\n\n/** Who is asking, once the gate let them through. */\nexport interface OutreachRouteCaller {\n uid: string\n /** The account's sign-in address, lower-cased, when the token carries one. */\n email: string | null\n staff: boolean\n orgId: string\n /** The organization document the entitlement was read from. */\n org: Record<string, unknown>\n /** An owner or admin of the organization, who may send from any member's mailbox. */\n isOrgAdmin: boolean\n}\n\n/**\n * A catalog permission a route asks for beyond `outreach.use`, with its\n * refusal — `data.manage` for a route that reads the CRM's people, which\n * the contacts' own rules gate on.\n */\nexport interface OutreachRouteExtraPermission {\n key: string\n /** Why the route needs it, as the refusal says it. */\n refusal: string\n}\n\nfunction bearer(request: Request): string | null {\n const authorization = request.headers.get('authorization') ?? ''\n return authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : null\n}\n\nexport async function outreachRouteGate(\n request: Request,\n rawOrgId: unknown,\n deps: OutreachRouteGateDeps,\n extra: readonly OutreachRouteExtraPermission[] = [],\n): Promise<Response | OutreachRouteCaller> {\n const token = bearer(request)\n if (!token) return outreachRefusal(401, 'unauthenticated', 'Sign in to use Sequences.')\n let decoded: DecodedIdToken\n try {\n decoded = await deps.verifyIdToken(token)\n } catch (error) {\n // A refused credential is the caller's; an Auth outage is ours and\n // propagates, so it answers 500 instead of \"sign in again\".\n if (invalidIdTokenResponse(error)) {\n return outreachRefusal(401, 'unauthenticated', 'Your sign-in could not be confirmed. Sign in again.')\n }\n throw error\n }\n if (!isEmailVerified(decoded) && !isImpersonationSession(decoded)) {\n return outreachRefusal(403, 'email-unverified', 'Verify your email address before using Sequences.')\n }\n\n const orgId = readOutreachDocumentId(rawOrgId)\n if (!orgId) return outreachRefusal(400, 'org-required', 'Open an organization before using Sequences.')\n\n const membership = await deps.resolveOrgPermissions(decoded.uid, { orgId })\n // A targeted org the account is not on answers with that org's id and no\n // role — as does a lookup that failed closed — so the role is the test.\n if (!membership.role || membership.orgId !== orgId) {\n return outreachRefusal(403, 'not-a-member', 'You are not a member of that organization.')\n }\n if (!membership.orgWide) {\n return outreachRefusal(\n 403,\n 'not-org-wide',\n 'Sequences covers the whole organization, and your access is to particular sites.',\n )\n }\n if (membership.permissions[OUTREACH_USE_PERMISSION] !== true) {\n return outreachRefusal(403, 'permission', 'Your role does not include Use Sequences.')\n }\n for (const permission of extra) {\n if (!(await deps.holdsOrgCatalogPermission(decoded.uid, orgId, permission.key))) {\n return outreachRefusal(403, 'permission', permission.refusal)\n }\n }\n\n const org = (await deps.readOrg(orgId)) ?? {}\n if (!checkEntitlement(org, 'outreach')) {\n return outreachRefusal(403, 'entitlement', \"Sequences isn't available to this workspace yet.\")\n }\n const staff = decoded['staff'] === true\n const locked = await deps.lockdownRefusal({ request, staff, uid: decoded.uid, org })\n if (locked) return locked\n\n return {\n uid: decoded.uid,\n email: typeof decoded.email === 'string' ? decoded.email.trim().toLowerCase() : null,\n staff,\n orgId,\n org,\n isOrgAdmin: membership.isOwner,\n }\n}\n"],"names":["checkEntitlement","isEmailVerified","isImpersonationSession","invalidIdTokenResponse","OUTREACH_USE_PERMISSION","outreachRefusal","readOutreachDocumentId","bearer","request","authorization","headers","get","startsWith","slice","length","outreachRouteGate","rawOrgId","deps","extra","token","decoded","verifyIdToken","error","orgId","membership","resolveOrgPermissions","uid","role","orgWide","permissions","permission","holdsOrgCatalogPermission","key","refusal","org","readOrg","staff","locked","lockdownRefusal","email","trim","toLowerCase","isOrgAdmin","isOwner"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,gBAAgB,QAAQ,sBAAqB;AACtD,SACEC,eAAe,EACfC,sBAAsB,QACjB,iDAAgD;AACvD,SAASC,sBAAsB,QAAQ,mDAAkD;AAEzF,SAASC,uBAAuB,QAAQ,gCAA4B;AACpE,SAASC,eAAe,EAAEC,sBAAsB,QAAQ,kBAAc;AAmFtE,SAASC,OAAOC,OAAgB;QACRA;IAAtB,MAAMC,iBAAgBD,uBAAAA,QAAQE,OAAO,CAACC,GAAG,CAAC,4BAApBH,uBAAwC;IAC9D,OAAOC,cAAcG,UAAU,CAAC,aAAaH,cAAcI,KAAK,CAAC,UAAUC,MAAM,IAAI;AACvF;AAEA,OAAO,eAAeC,kBACpBP,OAAgB,EAChBQ,QAAiB,EACjBC,IAA2B,EAC3BC,QAAiD,EAAE;QA4CtC;IA1Cb,MAAMC,QAAQZ,OAAOC;IACrB,IAAI,CAACW,OAAO,OAAOd,gBAAgB,KAAK,mBAAmB;IAC3D,IAAIe;IACJ,IAAI;QACFA,UAAU,MAAMH,KAAKI,aAAa,CAACF;IACrC,EAAE,OAAOG,OAAO;QACd,mEAAmE;QACnE,4DAA4D;QAC5D,IAAInB,uBAAuBmB,QAAQ;YACjC,OAAOjB,gBAAgB,KAAK,mBAAmB;QACjD;QACA,MAAMiB;IACR;IACA,IAAI,CAACrB,gBAAgBmB,YAAY,CAAClB,uBAAuBkB,UAAU;QACjE,OAAOf,gBAAgB,KAAK,oBAAoB;IAClD;IAEA,MAAMkB,QAAQjB,uBAAuBU;IACrC,IAAI,CAACO,OAAO,OAAOlB,gBAAgB,KAAK,gBAAgB;IAExD,MAAMmB,aAAa,MAAMP,KAAKQ,qBAAqB,CAACL,QAAQM,GAAG,EAAE;QAAEH;IAAM;IACzE,yEAAyE;IACzE,wEAAwE;IACxE,IAAI,CAACC,WAAWG,IAAI,IAAIH,WAAWD,KAAK,KAAKA,OAAO;QAClD,OAAOlB,gBAAgB,KAAK,gBAAgB;IAC9C;IACA,IAAI,CAACmB,WAAWI,OAAO,EAAE;QACvB,OAAOvB,gBACL,KACA,gBACA;IAEJ;IACA,IAAImB,WAAWK,WAAW,CAACzB,wBAAwB,KAAK,MAAM;QAC5D,OAAOC,gBAAgB,KAAK,cAAc;IAC5C;IACA,KAAK,MAAMyB,cAAcZ,MAAO;QAC9B,IAAI,CAAE,MAAMD,KAAKc,yBAAyB,CAACX,QAAQM,GAAG,EAAEH,OAAOO,WAAWE,GAAG,GAAI;YAC/E,OAAO3B,gBAAgB,KAAK,cAAcyB,WAAWG,OAAO;QAC9D;IACF;IAEA,MAAMC,OAAO,OAAA,MAAMjB,KAAKkB,OAAO,CAACZ,kBAAnB,OAA8B,CAAC;IAC5C,IAAI,CAACvB,iBAAiBkC,KAAK,aAAa;QACtC,OAAO7B,gBAAgB,KAAK,eAAe;IAC7C;IACA,MAAM+B,QAAQhB,OAAO,CAAC,QAAQ,KAAK;IACnC,MAAMiB,SAAS,MAAMpB,KAAKqB,eAAe,CAAC;QAAE9B;QAAS4B;QAAOV,KAAKN,QAAQM,GAAG;QAAEQ;IAAI;IAClF,IAAIG,QAAQ,OAAOA;IAEnB,OAAO;QACLX,KAAKN,QAAQM,GAAG;QAChBa,OAAO,OAAOnB,QAAQmB,KAAK,KAAK,WAAWnB,QAAQmB,KAAK,CAACC,IAAI,GAAGC,WAAW,KAAK;QAChFL;QACAb;QACAW;QACAQ,YAAYlB,WAAWmB,OAAO;IAChC;AACF"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/route-gate.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n isEmailVerified,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport type { DecodedIdToken } from 'firebase-admin/auth'\nimport { outreachEntitlementRefusal, outreachMembershipRefusal } from '../engine/outreach-access'\nimport { outreachRefusal, readOutreachDocumentId } from './route-http'\n\n/**\n * THE GATE EVERY SETTINGS, SEQUENCE AND ENROLLMENT ROUTE CLIMBS (AGL-2980).\n *\n * The console dispatcher has already refused a request while\n * `release_outreach` is off for it, an unverified account's token, a\n * lockdown with no org in scope, and a caller over the console rate limit —\n * every route here is registered under the `outreach/` prefix, which is what\n * puts it behind those. What the dispatcher cannot know is anything about\n * the member, so each rung below is this plugin's:\n *\n * 401 no bearer token, or one the verifier refused\n * 403 the account's address is unverified (an impersonation session is exempt)\n * 400 the request did not name an organization\n * 403 not a member of it, a site collaborator without org-wide reach, or\n * without `outreach.use` — or without a permission the route adds\n * 403 the organization does not hold the `outreach` entitlement\n * 423 lockdown, now with the organization and the account in scope\n *\n * The reach is org-wide for the reason the rules give: a sequence enrolls\n * people from every site of the organization, so none of it is a fact\n * about the one site a collaborator was invited to.\n *\n * `resolveOrgPermissions` is always given the concrete org id. Called\n * without one it resolves a caller with no org at all as that future org's\n * owner, which is right for a fresh signup and wrong for every question\n * asked here.\n */\n\nexport interface OutreachRouteGateDeps {\n verifyIdToken(idToken: string): Promise<DecodedIdToken>\n resolveOrgPermissions(\n uid: string,\n context: { orgId: string },\n ): Promise<{\n orgId: string | null\n /** `null` when the account is not on the organization's roster. */\n role: string | null\n isOwner: boolean\n orgWide: boolean\n permissions: Partial<Record<string, boolean | undefined>>\n }>\n /**\n * Whether the member holds a CATALOG permission (`data.manage`) as the\n * org's roles resolve it. Asked separately because the map\n * `resolveOrgPermissions` answers carries the legacy keys and the plugins'\n * own, never a catalog key: read there, a catalog key refuses everyone.\n */\n holdsOrgCatalogPermission(uid: string, orgId: string, key: string): Promise<boolean>\n readOrg(orgId: string): Promise<Record<string, unknown> | null>\n lockdownRefusal(options: {\n request: Request\n staff: boolean\n uid: string\n org: Record<string, unknown>\n }): Promise<Response | null>\n}\n\n/** Who is asking, once the gate let them through. */\nexport interface OutreachRouteCaller {\n uid: string\n /** The account's sign-in address, lower-cased, when the token carries one. */\n email: string | null\n staff: boolean\n orgId: string\n /** The organization document the entitlement was read from. */\n org: Record<string, unknown>\n /** An owner or admin of the organization, who may send from any member's mailbox. */\n isOrgAdmin: boolean\n}\n\n/**\n * A catalog permission a route asks for beyond `outreach.use`, with its\n * refusal — `data.manage` for a route that reads the CRM's people, which\n * the contacts' own rules gate on.\n */\nexport interface OutreachRouteExtraPermission {\n key: string\n /** Why the route needs it, as the refusal says it. */\n refusal: string\n}\n\nfunction bearer(request: Request): string | null {\n const authorization = request.headers.get('authorization') ?? ''\n return authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : null\n}\n\nexport async function outreachRouteGate(\n request: Request,\n rawOrgId: unknown,\n deps: OutreachRouteGateDeps,\n extra: readonly OutreachRouteExtraPermission[] = [],\n): Promise<Response | OutreachRouteCaller> {\n const token = bearer(request)\n if (!token) return outreachRefusal(401, 'unauthenticated', 'Sign in to use Sequences.')\n let decoded: DecodedIdToken\n try {\n decoded = await deps.verifyIdToken(token)\n } catch (error) {\n // A refused credential is the caller's; an Auth outage is ours and\n // propagates, so it answers 500 instead of \"sign in again\".\n if (invalidIdTokenResponse(error)) {\n return outreachRefusal(401, 'unauthenticated', 'Your sign-in could not be confirmed. Sign in again.')\n }\n throw error\n }\n if (!isEmailVerified(decoded) && !isImpersonationSession(decoded)) {\n return outreachRefusal(403, 'email-unverified', 'Verify your email address before using Sequences.')\n }\n\n const orgId = readOutreachDocumentId(rawOrgId)\n if (!orgId) return outreachRefusal(400, 'org-required', 'Open an organization before using Sequences.')\n\n const membership = await deps.resolveOrgPermissions(decoded.uid, { orgId })\n const refused = outreachMembershipRefusal(membership, orgId)\n if (refused) return outreachRefusal(refused.status, refused.code, refused.message)\n for (const permission of extra) {\n if (!(await deps.holdsOrgCatalogPermission(decoded.uid, orgId, permission.key))) {\n return outreachRefusal(403, 'permission', permission.refusal)\n }\n }\n\n const org = (await deps.readOrg(orgId)) ?? {}\n const unentitled = outreachEntitlementRefusal(org)\n if (unentitled) return outreachRefusal(unentitled.status, unentitled.code, unentitled.message)\n const staff = decoded['staff'] === true\n const locked = await deps.lockdownRefusal({ request, staff, uid: decoded.uid, org })\n if (locked) return locked\n\n return {\n uid: decoded.uid,\n email: typeof decoded.email === 'string' ? decoded.email.trim().toLowerCase() : null,\n staff,\n orgId,\n org,\n isOrgAdmin: membership.isOwner,\n }\n}\n"],"names":["isEmailVerified","isImpersonationSession","invalidIdTokenResponse","outreachEntitlementRefusal","outreachMembershipRefusal","outreachRefusal","readOutreachDocumentId","bearer","request","authorization","headers","get","startsWith","slice","length","outreachRouteGate","rawOrgId","deps","extra","token","decoded","verifyIdToken","error","orgId","membership","resolveOrgPermissions","uid","refused","status","code","message","permission","holdsOrgCatalogPermission","key","refusal","org","readOrg","unentitled","staff","locked","lockdownRefusal","email","trim","toLowerCase","isOrgAdmin","isOwner"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,eAAe,EACfC,sBAAsB,QACjB,iDAAgD;AACvD,SAASC,sBAAsB,QAAQ,mDAAkD;AAEzF,SAASC,0BAA0B,EAAEC,yBAAyB,QAAQ,+BAA2B;AACjG,SAASC,eAAe,EAAEC,sBAAsB,QAAQ,kBAAc;AAmFtE,SAASC,OAAOC,OAAgB;QACRA;IAAtB,MAAMC,iBAAgBD,uBAAAA,QAAQE,OAAO,CAACC,GAAG,CAAC,4BAApBH,uBAAwC;IAC9D,OAAOC,cAAcG,UAAU,CAAC,aAAaH,cAAcI,KAAK,CAAC,UAAUC,MAAM,IAAI;AACvF;AAEA,OAAO,eAAeC,kBACpBP,OAAgB,EAChBQ,QAAiB,EACjBC,IAA2B,EAC3BC,QAAiD,EAAE;QA+BtC;IA7Bb,MAAMC,QAAQZ,OAAOC;IACrB,IAAI,CAACW,OAAO,OAAOd,gBAAgB,KAAK,mBAAmB;IAC3D,IAAIe;IACJ,IAAI;QACFA,UAAU,MAAMH,KAAKI,aAAa,CAACF;IACrC,EAAE,OAAOG,OAAO;QACd,mEAAmE;QACnE,4DAA4D;QAC5D,IAAIpB,uBAAuBoB,QAAQ;YACjC,OAAOjB,gBAAgB,KAAK,mBAAmB;QACjD;QACA,MAAMiB;IACR;IACA,IAAI,CAACtB,gBAAgBoB,YAAY,CAACnB,uBAAuBmB,UAAU;QACjE,OAAOf,gBAAgB,KAAK,oBAAoB;IAClD;IAEA,MAAMkB,QAAQjB,uBAAuBU;IACrC,IAAI,CAACO,OAAO,OAAOlB,gBAAgB,KAAK,gBAAgB;IAExD,MAAMmB,aAAa,MAAMP,KAAKQ,qBAAqB,CAACL,QAAQM,GAAG,EAAE;QAAEH;IAAM;IACzE,MAAMI,UAAUvB,0BAA0BoB,YAAYD;IACtD,IAAII,SAAS,OAAOtB,gBAAgBsB,QAAQC,MAAM,EAAED,QAAQE,IAAI,EAAEF,QAAQG,OAAO;IACjF,KAAK,MAAMC,cAAcb,MAAO;QAC9B,IAAI,CAAE,MAAMD,KAAKe,yBAAyB,CAACZ,QAAQM,GAAG,EAAEH,OAAOQ,WAAWE,GAAG,GAAI;YAC/E,OAAO5B,gBAAgB,KAAK,cAAc0B,WAAWG,OAAO;QAC9D;IACF;IAEA,MAAMC,OAAO,OAAA,MAAMlB,KAAKmB,OAAO,CAACb,kBAAnB,OAA8B,CAAC;IAC5C,MAAMc,aAAalC,2BAA2BgC;IAC9C,IAAIE,YAAY,OAAOhC,gBAAgBgC,WAAWT,MAAM,EAAES,WAAWR,IAAI,EAAEQ,WAAWP,OAAO;IAC7F,MAAMQ,QAAQlB,OAAO,CAAC,QAAQ,KAAK;IACnC,MAAMmB,SAAS,MAAMtB,KAAKuB,eAAe,CAAC;QAAEhC;QAAS8B;QAAOZ,KAAKN,QAAQM,GAAG;QAAES;IAAI;IAClF,IAAII,QAAQ,OAAOA;IAEnB,OAAO;QACLb,KAAKN,QAAQM,GAAG;QAChBe,OAAO,OAAOrB,QAAQqB,KAAK,KAAK,WAAWrB,QAAQqB,KAAK,CAACC,IAAI,GAAGC,WAAW,KAAK;QAChFL;QACAf;QACAY;QACAS,YAAYpB,WAAWqB,OAAO;IAChC;AACF"}
@@ -15,7 +15,11 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  import type { PluginWebApiHandler } from '@aglyn/aglyn/server';
18
+ import type { OutreachSequence } from '../model/outreach.types';
19
+ import { type OutreachSequenceDraft, type OutreachSequenceIssue } from '../model/sequence-draft';
18
20
  import { type OutreachRouteDeps } from './route-deps';
21
+ import { type OutreachRouteCaller } from './route-gate';
22
+ import type { OutreachRouteRefusalReason } from '../model/outreach-api';
19
23
  /** The activity line each lifecycle act writes. */
20
24
  export declare const OUTREACH_SEQUENCE_ACTIVITY: {
21
25
  readonly create: "Created a sequence";
@@ -29,4 +33,46 @@ export interface OutreachSequenceRoutes {
29
33
  status: PluginWebApiHandler;
30
34
  remove: PluginWebApiHandler;
31
35
  }
36
+ /** What saving a sequence came to: the stored sequence, or the refusal the route answers with. */
37
+ export type OutreachSequenceSaveOutcome = {
38
+ ok: true;
39
+ sequence: OutreachSequence;
40
+ created: boolean;
41
+ warnings: OutreachSequenceIssue[];
42
+ } | {
43
+ ok: false;
44
+ status: number;
45
+ code: OutreachRouteRefusalReason;
46
+ message: string;
47
+ issues?: OutreachSequenceIssue[];
48
+ };
49
+ /**
50
+ * Saves a sequence the way the editor's Save does (see "Saving" above):
51
+ * the editor's validator, the organization's placement rules and — once
52
+ * anyone is enrolled — the fixed steps, site and mailbox. A new sequence is
53
+ * a draft; an edit keeps its status. The save route and the package import
54
+ * (AGL-3535) both write through here. `newId` names a new sequence's id
55
+ * (an import keeps the package's); `judgeOnly` answers the verdict without
56
+ * writing, for an import's dry run.
57
+ */
58
+ export declare function saveOutreachSequence(deps: Pick<OutreachRouteDeps, 'firestore' | 'now' | 'logOrgActivity'>, caller: OutreachRouteCaller, input: {
59
+ sequenceId: string | null;
60
+ draft: OutreachSequenceDraft;
61
+ newId?: string;
62
+ judgeOnly?: boolean;
63
+ }): Promise<OutreachSequenceSaveOutcome>;
64
+ /**
65
+ * Deletes a draft nobody was ever enrolled in — the only sequence that may
66
+ * be deleted: what people were sent, and why they stopped, is the record of
67
+ * what the organization did. The delete route and a package import's undo
68
+ * (AGL-3535) both delete through here.
69
+ */
70
+ export declare function removeOutreachSequence(deps: Pick<OutreachRouteDeps, 'firestore' | 'logOrgActivity'>, caller: Pick<OutreachRouteCaller, 'orgId' | 'uid' | 'email'>, sequenceId: string): Promise<{
71
+ ok: true;
72
+ } | {
73
+ ok: false;
74
+ status: number;
75
+ code: OutreachRouteRefusalReason;
76
+ message: string;
77
+ }>;
32
78
  export declare function createOutreachSequenceRoutes(deps: OutreachRouteDeps): OutreachSequenceRoutes;
@@ -194,6 +194,148 @@ draft, options = {}) {
194
194
  return true;
195
195
  });
196
196
  }
197
+ /**
198
+ * Saves a sequence the way the editor's Save does (see "Saving" above):
199
+ * the editor's validator, the organization's placement rules and — once
200
+ * anyone is enrolled — the fixed steps, site and mailbox. A new sequence is
201
+ * a draft; an edit keeps its status. The save route and the package import
202
+ * (AGL-3535) both write through here. `newId` names a new sequence's id
203
+ * (an import keeps the package's); `judgeOnly` answers the verdict without
204
+ * writing, for an import's dry run.
205
+ */ export async function saveOutreachSequence(deps, caller, input) {
206
+ var _ref, _ref1, _ref2;
207
+ const firestore = deps.firestore();
208
+ const { sequenceId, draft } = input;
209
+ const existing = sequenceId ? await loadSequence(firestore, caller.orgId, sequenceId) : null;
210
+ if (sequenceId && !existing && !input.newId) {
211
+ return {
212
+ ok: false,
213
+ status: 404,
214
+ code: 'sequence-not-found',
215
+ message: 'That sequence no longer exists.'
216
+ };
217
+ }
218
+ if ((existing == null ? void 0 : existing.status) === 'archived') {
219
+ return {
220
+ ok: false,
221
+ status: 409,
222
+ code: 'sequence-archived',
223
+ message: "An archived sequence can't be edited."
224
+ };
225
+ }
226
+ // A sequence sends as its mailbox's member, so it is theirs (or an
227
+ // admin's) to change — the stored mailbox as well as a new one: moving
228
+ // a colleague's sequence onto your own mailbox is still changing theirs.
229
+ if ((existing == null ? void 0 : existing.mailboxId) && existing.mailboxId !== draft.mailboxId) {
230
+ const stored = mailboxIssues(caller, await loadMailbox(firestore, caller.orgId, existing.mailboxId));
231
+ const refused = stored.find((entry)=>entry.code === 'mailbox_not_yours');
232
+ if (refused) return {
233
+ ok: false,
234
+ status: 403,
235
+ code: 'permission',
236
+ message: refused.message
237
+ };
238
+ }
239
+ const judged = [
240
+ ...validateOutreachSequence(draft),
241
+ ...await draftPlacementIssues(firestore, caller, draft)
242
+ ];
243
+ const ownership = judged.find((entry)=>entry.code === 'mailbox_not_yours');
244
+ if (ownership) return {
245
+ ok: false,
246
+ status: 403,
247
+ code: 'permission',
248
+ message: ownership.message
249
+ };
250
+ if (existing && await hasEnrollments(firestore, caller.orgId, existing.id)) {
251
+ judged.push(...outreachEnrolledSequenceIssues(existing, draft));
252
+ }
253
+ const errors = judged.filter((entry)=>entry.severity === 'error');
254
+ if (errors.length) {
255
+ return {
256
+ ok: false,
257
+ status: 400,
258
+ code: 'invalid-sequence',
259
+ message: errors[0].message,
260
+ issues: judged
261
+ };
262
+ }
263
+ const nowMs = deps.now();
264
+ const ref = outreachOrgCollection(firestore, caller.orgId, 'sequences').doc((_ref = (_ref1 = existing == null ? void 0 : existing.id) != null ? _ref1 : input.newId) != null ? _ref : createResourceUid());
265
+ const sequence = _extends({
266
+ id: ref.id
267
+ }, draft, {
268
+ status: (_ref2 = existing == null ? void 0 : existing.status) != null ? _ref2 : 'draft'
269
+ }, (existing == null ? void 0 : existing.stats) ? {
270
+ stats: existing.stats
271
+ } : {}, {
272
+ createdAtMs: (existing == null ? void 0 : existing.createdAtMs) || nowMs,
273
+ updatedAtMs: nowMs
274
+ });
275
+ const warnings = judged.filter((entry)=>entry.severity === 'warning');
276
+ if (input.judgeOnly) return {
277
+ ok: true,
278
+ sequence,
279
+ created: !existing,
280
+ warnings
281
+ };
282
+ /*
283
+ * The list's search fields ride with every write of the name (AGL-3321):
284
+ * the sequences list asks Firestore for them, and a sequence missing
285
+ * them would list normally and never be found.
286
+ */ await ref.set(_extends({}, sequence, nameSearchFields(sequence.name)));
287
+ if (!existing) {
288
+ await deps.logOrgActivity(caller.orgId, {
289
+ uid: caller.uid,
290
+ email: caller.email
291
+ }, OUTREACH_SEQUENCE_ACTIVITY.create, {
292
+ type: OUTREACH_SEQUENCE_ACTIVITY_TARGET,
293
+ id: sequence.id,
294
+ name: sequence.name
295
+ });
296
+ }
297
+ return {
298
+ ok: true,
299
+ sequence,
300
+ created: !existing,
301
+ warnings
302
+ };
303
+ }
304
+ /**
305
+ * Deletes a draft nobody was ever enrolled in — the only sequence that may
306
+ * be deleted: what people were sent, and why they stopped, is the record of
307
+ * what the organization did. The delete route and a package import's undo
308
+ * (AGL-3535) both delete through here.
309
+ */ export async function removeOutreachSequence(deps, caller, sequenceId) {
310
+ const firestore = deps.firestore();
311
+ const sequence = await loadSequence(firestore, caller.orgId, sequenceId);
312
+ if (!sequence) return {
313
+ ok: false,
314
+ status: 404,
315
+ code: 'sequence-not-found',
316
+ message: 'That sequence no longer exists.'
317
+ };
318
+ if (sequence.status !== 'draft' || await hasEnrollments(firestore, caller.orgId, sequence.id)) {
319
+ return {
320
+ ok: false,
321
+ status: 409,
322
+ code: 'delete-refused',
323
+ message: 'Only a draft nobody was enrolled in can be deleted. Archive this sequence instead.'
324
+ };
325
+ }
326
+ await outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(sequence.id).delete();
327
+ await deps.logOrgActivity(caller.orgId, {
328
+ uid: caller.uid,
329
+ email: caller.email
330
+ }, OUTREACH_SEQUENCE_ACTIVITY.delete, {
331
+ type: OUTREACH_SEQUENCE_ACTIVITY_TARGET,
332
+ id: sequence.id,
333
+ name: sequence.name
334
+ });
335
+ return {
336
+ ok: true
337
+ };
338
+ }
197
339
  export function createOutreachSequenceRoutes(deps) {
198
340
  const activity = (caller, action, sequence)=>deps.logOrgActivity(caller.orgId, {
199
341
  uid: caller.uid,
@@ -204,69 +346,27 @@ export function createOutreachSequenceRoutes(deps) {
204
346
  name: sequence.name
205
347
  });
206
348
  const save = async (request)=>{
207
- var _ref;
208
349
  if (request.method !== 'POST') return outreachMethodNotAllowed('POST');
209
350
  const body = await readOutreachJsonBody(request);
210
351
  const caller = await outreachRouteGate(request, body['orgId'], deps.gate);
211
352
  if (caller instanceof Response) return caller;
212
- const firestore = deps.firestore();
213
353
  const rawId = body['sequenceId'];
214
354
  const sequenceId = rawId === undefined || rawId === null || rawId === '' ? null : readOutreachDocumentId(rawId);
215
355
  if (rawId && !sequenceId) return outreachRefusal(400, 'invalid-request', 'Name the sequence to save.');
216
- const existing = sequenceId ? await loadSequence(firestore, caller.orgId, sequenceId) : null;
217
- if (sequenceId && !existing) {
218
- return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.');
219
- }
220
- if ((existing == null ? void 0 : existing.status) === 'archived') {
221
- return outreachRefusal(409, 'sequence-archived', "An archived sequence can't be edited.");
222
- }
223
- const draft = readOutreachSequenceDraft(body['sequence']);
224
- // A sequence sends as its mailbox's member, so it is theirs (or an
225
- // admin's) to change — the stored mailbox as well as a new one: moving
226
- // a colleague's sequence onto your own mailbox is still changing theirs.
227
- if ((existing == null ? void 0 : existing.mailboxId) && existing.mailboxId !== draft.mailboxId) {
228
- const stored = mailboxIssues(caller, await loadMailbox(firestore, caller.orgId, existing.mailboxId));
229
- const refused = stored.find((entry)=>entry.code === 'mailbox_not_yours');
230
- if (refused) return outreachRefusal(403, 'permission', refused.message);
231
- }
232
- const judged = [
233
- ...validateOutreachSequence(draft),
234
- ...await draftPlacementIssues(firestore, caller, draft)
235
- ];
236
- const ownership = judged.find((entry)=>entry.code === 'mailbox_not_yours');
237
- if (ownership) return outreachRefusal(403, 'permission', ownership.message);
238
- if (existing && await hasEnrollments(firestore, caller.orgId, existing.id)) {
239
- judged.push(...outreachEnrolledSequenceIssues(existing, draft));
240
- }
241
- const errors = judged.filter((entry)=>entry.severity === 'error');
242
- if (errors.length) {
243
- return outreachRefusal(400, 'invalid-sequence', errors[0].message, {
244
- issues: judged
245
- });
246
- }
247
- const nowMs = deps.now();
248
- const ref = existing ? outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(existing.id) : outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(createResourceUid());
249
- const sequence = _extends({
250
- id: ref.id
251
- }, draft, {
252
- status: (_ref = existing == null ? void 0 : existing.status) != null ? _ref : 'draft'
253
- }, (existing == null ? void 0 : existing.stats) ? {
254
- stats: existing.stats
255
- } : {}, {
256
- createdAtMs: (existing == null ? void 0 : existing.createdAtMs) || nowMs,
257
- updatedAtMs: nowMs
356
+ const outcome = await saveOutreachSequence(deps, caller, {
357
+ sequenceId,
358
+ draft: readOutreachSequenceDraft(body['sequence'])
258
359
  });
259
- /*
260
- * The list's search fields ride with every write of the name (AGL-3321):
261
- * the sequences list asks Firestore for them, and a sequence missing
262
- * them would list normally and never be found.
263
- */ await ref.set(_extends({}, sequence, nameSearchFields(sequence.name)));
264
- if (!existing) await activity(caller, OUTREACH_SEQUENCE_ACTIVITY.create, sequence);
360
+ if (outcome.ok === false) {
361
+ return outreachRefusal(outcome.status, outcome.code, outcome.message, outcome.issues ? {
362
+ issues: outcome.issues
363
+ } : undefined);
364
+ }
265
365
  return outreachOk({
266
366
  ok: true,
267
- sequence,
268
- created: !existing,
269
- warnings: judged.filter((entry)=>entry.severity === 'warning')
367
+ sequence: outcome.sequence,
368
+ created: outcome.created,
369
+ warnings: outcome.warnings
270
370
  });
271
371
  };
272
372
  const status = async (request)=>{
@@ -332,16 +432,8 @@ export function createOutreachSequenceRoutes(deps) {
332
432
  if (caller instanceof Response) return caller;
333
433
  const sequenceId = readOutreachDocumentId(body['sequenceId']);
334
434
  if (!sequenceId) return outreachRefusal(400, 'invalid-request', 'Name the sequence to delete.');
335
- const firestore = deps.firestore();
336
- const sequence = await loadSequence(firestore, caller.orgId, sequenceId);
337
- if (!sequence) return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.');
338
- if (sequence.status !== 'draft' || await hasEnrollments(firestore, caller.orgId, sequence.id)) {
339
- // What people were sent, and why they stopped, is the record of what
340
- // this organization did; only a draft nobody was enrolled in has none.
341
- return outreachRefusal(409, 'delete-refused', 'Only a draft nobody was enrolled in can be deleted. Archive this sequence instead.');
342
- }
343
- await outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(sequence.id).delete();
344
- await activity(caller, OUTREACH_SEQUENCE_ACTIVITY.delete, sequence);
435
+ const outcome = await removeOutreachSequence(deps, caller, sequenceId);
436
+ if (outcome.ok === false) return outreachRefusal(outcome.status, outcome.code, outcome.message);
345
437
  return outreachOk({
346
438
  ok: true
347
439
  });