@aglyn/plugins-outreach 1.0.0-beta.224 → 1.0.0-beta.225
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -4
- package/package.json +14 -14
- package/src/lib/components/enrollment-detail.d.ts +8 -0
- package/src/lib/components/enrollment-detail.js +17 -5
- package/src/lib/components/enrollment-detail.js.map +1 -1
- package/src/lib/components/mailbox-card.d.ts +3 -3
- package/src/lib/components/mailbox-card.js +11 -5
- package/src/lib/components/mailbox-card.js.map +1 -1
- package/src/lib/components/mailboxes-section.d.ts +6 -5
- package/src/lib/components/mailboxes-section.js +61 -26
- package/src/lib/components/mailboxes-section.js.map +1 -1
- package/src/lib/components/sequence-detail.js +1 -1
- package/src/lib/components/sequence-detail.js.map +1 -1
- package/src/lib/components/sequence-editor.js +29 -4
- package/src/lib/components/sequence-editor.js.map +1 -1
- package/src/lib/components/sequence-mailbox-picker.d.ts +21 -0
- package/src/lib/components/sequence-mailbox-picker.js +74 -13
- package/src/lib/components/sequence-mailbox-picker.js.map +1 -1
- package/src/lib/components/use-outreach-mailbox-api.d.ts +10 -2
- package/src/lib/components/use-outreach-mailbox-api.js +7 -2
- package/src/lib/components/use-outreach-mailbox-api.js.map +1 -1
- package/src/lib/engine/enrollment-state.d.ts +6 -0
- package/src/lib/engine/enrollment-state.js +1 -1
- package/src/lib/engine/enrollment-state.js.map +1 -1
- package/src/lib/engine/index.d.ts +1 -0
- package/src/lib/engine/index.js +1 -0
- package/src/lib/engine/index.js.map +1 -1
- package/src/lib/engine/mailbox-notice.d.ts +5 -2
- package/src/lib/engine/mailbox-notice.js +6 -2
- package/src/lib/engine/mailbox-notice.js.map +1 -1
- package/src/lib/engine/mailbox-rotation.d.ts +41 -0
- package/src/lib/engine/mailbox-rotation.js +71 -0
- package/src/lib/engine/mailbox-rotation.js.map +1 -0
- package/src/lib/mailboxes/mailbox-api.d.ts +30 -11
- package/src/lib/mailboxes/mailbox-api.js +7 -3
- package/src/lib/mailboxes/mailbox-api.js.map +1 -1
- package/src/lib/mailboxes/mailbox-credentials.d.ts +15 -12
- package/src/lib/mailboxes/mailbox-credentials.js +12 -8
- package/src/lib/mailboxes/mailbox-credentials.js.map +1 -1
- package/src/lib/mailboxes/mailbox-erasure.js +3 -0
- package/src/lib/mailboxes/mailbox-erasure.js.map +1 -1
- package/src/lib/mailboxes/mailbox-revoke.d.ts +10 -5
- package/src/lib/mailboxes/mailbox-revoke.js +1 -0
- package/src/lib/mailboxes/mailbox-revoke.js.map +1 -1
- package/src/lib/mailboxes/mailbox-routes.d.ts +11 -3
- package/src/lib/mailboxes/mailbox-routes.js +218 -35
- package/src/lib/mailboxes/mailbox-routes.js.map +1 -1
- package/src/lib/mailboxes/mailbox-settings.d.ts +6 -2
- package/src/lib/mailboxes/mailbox-settings.js +5 -1
- package/src/lib/mailboxes/mailbox-settings.js.map +1 -1
- package/src/lib/mailboxes/mailbox-transport.d.ts +15 -9
- package/src/lib/mailboxes/mailbox-transport.js +47 -17
- package/src/lib/mailboxes/mailbox-transport.js.map +1 -1
- package/src/lib/mailboxes/oauth-state.d.ts +11 -3
- package/src/lib/mailboxes/oauth-state.js +17 -8
- package/src/lib/mailboxes/oauth-state.js.map +1 -1
- package/src/lib/mailboxes/outreach-config.d.ts +30 -0
- package/src/lib/mailboxes/outreach-config.js +53 -10
- package/src/lib/mailboxes/outreach-config.js.map +1 -1
- package/src/lib/mailboxes/register-mailbox-routes.js +2 -1
- package/src/lib/mailboxes/register-mailbox-routes.js.map +1 -1
- package/src/lib/model/outreach.types.d.ts +13 -2
- package/src/lib/model/outreach.types.js +7 -2
- package/src/lib/model/outreach.types.js.map +1 -1
- package/src/lib/model/sequence-draft.d.ts +6 -1
- package/src/lib/model/sequence-draft.js +16 -4
- package/src/lib/model/sequence-draft.js.map +1 -1
- package/src/lib/routes/enroll-routes.js +52 -1
- package/src/lib/routes/enroll-routes.js.map +1 -1
- package/src/lib/routes/sequence-routes.js +11 -3
- package/src/lib/routes/sequence-routes.js.map +1 -1
- package/src/lib/runtime/fixtures/fake-gmail.d.ts +18 -3
- package/src/lib/runtime/fixtures/fake-gmail.js +21 -1
- package/src/lib/runtime/fixtures/fake-gmail.js.map +1 -1
- package/src/lib/runtime/mailbox-notices.js +2 -2
- package/src/lib/runtime/mailbox-notices.js.map +1 -1
- package/src/lib/runtime/send-job.js +2 -8
- package/src/lib/runtime/send-job.js.map +1 -1
- package/src/lib/runtime/sync-job.js +24 -11
- package/src/lib/runtime/sync-job.js.map +1 -1
- package/src/lib/subprocessors.d.ts +12 -1
- package/src/lib/subprocessors.js +25 -2
- package/src/lib/subprocessors.js.map +1 -1
- package/src/lib/transfer/sequences-package.js +7 -2
- package/src/lib/transfer/sequences-package.js.map +1 -1
- package/src/lib/transport/gmail-client.d.ts +4 -11
- package/src/lib/transport/gmail-client.js +42 -17
- package/src/lib/transport/gmail-client.js.map +1 -1
- package/src/lib/transport/graph-client.d.ts +87 -0
- package/src/lib/transport/graph-client.js +359 -0
- package/src/lib/transport/graph-client.js.map +1 -0
- package/src/lib/transport/mail-client.d.ts +138 -0
- package/src/lib/transport/mail-client.js +34 -0
- package/src/lib/transport/mail-client.js.map +1 -0
- package/src/lib/transport/microsoft-errors.d.ts +32 -0
- package/src/lib/transport/microsoft-errors.js +94 -0
- package/src/lib/transport/microsoft-errors.js.map +1 -0
- package/src/lib/transport/microsoft-oauth.d.ts +140 -0
- package/src/lib/transport/microsoft-oauth.js +225 -0
- package/src/lib/transport/microsoft-oauth.js.map +1 -0
- package/src/lib/transport/mime-message.d.ts +30 -0
- package/src/lib/transport/mime-message.js +235 -0
- package/src/lib/transport/mime-message.js.map +1 -0
- package/src/lib/transport/send-message.d.ts +15 -8
- package/src/lib/transport/send-message.js +4 -3
- package/src/lib/transport/send-message.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/mailbox-transport.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 { SecretBoxError } from '@aglyn/shared-util-tools/secret-box'\nimport { createGmailClient, type GmailClient } from '../transport/gmail-client'\nimport type { TransportDeps } from '../transport/http'\nimport {\n mailboxCredentialsRef,\n mailboxRef,\n openMailboxRefreshToken,\n readMailboxCredentials,\n sealMailboxRefreshToken,\n type OutreachGoogleMailboxCredentials,\n} from './mailbox-credentials'\nimport {\n readOutreachGoogleConfig,\n type OutreachGoogleConfigResult,\n} from './outreach-config'\n\n/**\n * A CONNECTED MAILBOX, OPENED FOR SENDING (AGL-2978).\n *\n * The door the send and sync runtime, and the panel's test send, reach a\n * mailbox's Gmail through: the stored credential read, its refresh token\n * opened with the deployment's key, and a transport client built on it. The\n * token never leaves this function except inside the client.\n *\n * A token opened under an older key of the keyring is sealed again under the\n * current one on the way through, so a key rotation completes itself as\n * mailboxes are used rather than needing a migration.\n */\n\nexport type OpenedOutreachMailbox =\n | { ok: true; client: GmailClient; credential: OutreachGoogleMailboxCredentials }\n | {\n ok: false\n /**\n * - `not-configured` — this deployment has no Outreach client or key.\n * - `no-credential` — nothing is stored for the mailbox.\n * - `sealed-token-unreadable` — the stored token does not open with\n * this deployment's key (rotated away, or altered). The mailbox needs\n * connecting again.\n */\n reason: 'not-configured' | 'no-credential' | 'sealed-token-unreadable'\n }\n\nexport interface OpenMailboxDeps extends TransportDeps {\n readConfig?: () => OutreachGoogleConfigResult\n}\n\nexport async function openOutreachMailboxClient(\n firestore: FirebaseFirestore.Firestore,\n input: { mailboxId: string },\n deps: OpenMailboxDeps = {},\n): Promise<OpenedOutreachMailbox> {\n const config = (deps.readConfig ?? readOutreachGoogleConfig)()\n if (!config.configured) return { ok: false, reason: 'not-configured' }\n const snapshot = await mailboxCredentialsRef(firestore, input.mailboxId).get()\n const credential = readMailboxCredentials(snapshot.exists ? snapshot.data() : null)\n if (!credential || credential.mailboxId !== input.mailboxId) {\n return { ok: false, reason: 'no-credential' }\n }\n let opened: { refreshToken: string; needsReseal: boolean }\n try {\n opened = openMailboxRefreshToken(credential, config.config.keyring)\n } catch (error) {\n if (error instanceof SecretBoxError) return { ok: false, reason: 'sealed-token-unreadable' }\n throw error\n }\n if (opened.needsReseal) {\n await resealMailboxRefreshToken(firestore, credential, opened.refreshToken, config.config.keyring, deps.now)\n }\n const client = createGmailClient({\n ...deps,\n clientId: config.config.clientId,\n clientSecret: config.config.clientSecret,\n refreshToken: opened.refreshToken,\n })\n return { ok: true, client, credential }\n}\n\n/**\n * Seals a token again under the current key, only if the stored value is\n * still the one that was opened: a reconnect that landed in between wrote a\n * newer grant, and that one wins. Best-effort — a failure leaves the old\n * seal, which still opens.\n */\nasync function resealMailboxRefreshToken(\n firestore: FirebaseFirestore.Firestore,\n credential: OutreachGoogleMailboxCredentials,\n refreshToken: string,\n keyring: Parameters<typeof sealMailboxRefreshToken>[2],\n now: (() => number) | undefined,\n): Promise<void> {\n const ref = mailboxCredentialsRef(firestore, credential.mailboxId)\n try {\n await firestore.runTransaction(async (tx) => {\n const current = await tx.get(ref)\n if (current.get('sealedRefreshToken') !== credential.sealedRefreshToken) return\n tx.update(ref, {\n ...sealMailboxRefreshToken(refreshToken, credential.mailboxId, keyring),\n updatedAtMs: (now ?? Date.now)(),\n })\n })\n } catch (error) {\n console.error('[outreach] resealing a mailbox token failed', error)\n }\n}\n\n/**\n * Marks a mailbox as needing a reconnect: the provider refused its grant, or\n * its stored token cannot be opened. Nothing sends from it until the member\n * connects it again, which returns it to where it was.\n */\nexport async function markOutreachMailboxReconnectRequired(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; mailboxId: string; errorCode: string; nowMs: number },\n): Promise<void> {\n await mailboxRef(firestore, input.orgId, input.mailboxId)\n .update({\n status: 'reconnect_required',\n 'health.lastErrorAtMs': input.nowMs,\n 'health.lastErrorCode': input.errorCode,\n updatedAtMs: input.nowMs,\n })\n .catch((error: unknown) => {\n console.error('[outreach] marking a mailbox reconnect_required failed', error)\n })\n}\n"],"names":["SecretBoxError","createGmailClient","mailboxCredentialsRef","mailboxRef","openMailboxRefreshToken","readMailboxCredentials","sealMailboxRefreshToken","readOutreachGoogleConfig","openOutreachMailboxClient","firestore","input","deps","config","readConfig","configured","ok","reason","snapshot","mailboxId","get","credential","exists","data","opened","keyring","error","needsReseal","resealMailboxRefreshToken","refreshToken","now","client","clientId","clientSecret","ref","runTransaction","tx","current","sealedRefreshToken","update","updatedAtMs","Date","console","markOutreachMailboxReconnectRequired","orgId","status","nowMs","errorCode","catch"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,cAAc,QAAQ,sCAAqC;AACpE,SAASC,iBAAiB,QAA0B,+BAA2B;AAE/E,SACEC,qBAAqB,EACrBC,UAAU,EACVC,uBAAuB,EACvBC,sBAAsB,EACtBC,uBAAuB,QAElB,2BAAuB;AAC9B,SACEC,wBAAwB,QAEnB,uBAAmB;AAiC1B,OAAO,eAAeC,0BACpBC,SAAsC,EACtCC,KAA4B,EAC5BC,OAAwB,CAAC,CAAC;QAEVA;IAAhB,MAAMC,SAAS,EAACD,mBAAAA,KAAKE,UAAU,YAAfF,mBAAmBJ;IACnC,IAAI,CAACK,OAAOE,UAAU,EAAE,OAAO;QAAEC,IAAI;QAAOC,QAAQ;IAAiB;IACrE,MAAMC,WAAW,MAAMf,sBAAsBO,WAAWC,MAAMQ,SAAS,EAAEC,GAAG;IAC5E,MAAMC,aAAaf,uBAAuBY,SAASI,MAAM,GAAGJ,SAASK,IAAI,KAAK;IAC9E,IAAI,CAACF,cAAcA,WAAWF,SAAS,KAAKR,MAAMQ,SAAS,EAAE;QAC3D,OAAO;YAAEH,IAAI;YAAOC,QAAQ;QAAgB;IAC9C;IACA,IAAIO;IACJ,IAAI;QACFA,SAASnB,wBAAwBgB,YAAYR,OAAOA,MAAM,CAACY,OAAO;IACpE,EAAE,OAAOC,OAAO;QACd,IAAIA,iBAAiBzB,gBAAgB,OAAO;YAAEe,IAAI;YAAOC,QAAQ;QAA0B;QAC3F,MAAMS;IACR;IACA,IAAIF,OAAOG,WAAW,EAAE;QACtB,MAAMC,0BAA0BlB,WAAWW,YAAYG,OAAOK,YAAY,EAAEhB,OAAOA,MAAM,CAACY,OAAO,EAAEb,KAAKkB,GAAG;IAC7G;IACA,MAAMC,SAAS7B,kBAAkB,aAC5BU;QACHoB,UAAUnB,OAAOA,MAAM,CAACmB,QAAQ;QAChCC,cAAcpB,OAAOA,MAAM,CAACoB,YAAY;QACxCJ,cAAcL,OAAOK,YAAY;;IAEnC,OAAO;QAAEb,IAAI;QAAMe;QAAQV;IAAW;AACxC;AAEA;;;;;CAKC,GACD,eAAeO,0BACblB,SAAsC,EACtCW,UAA4C,EAC5CQ,YAAoB,EACpBJ,OAAsD,EACtDK,GAA+B;IAE/B,MAAMI,MAAM/B,sBAAsBO,WAAWW,WAAWF,SAAS;IACjE,IAAI;QACF,MAAMT,UAAUyB,cAAc,CAAC,OAAOC;YACpC,MAAMC,UAAU,MAAMD,GAAGhB,GAAG,CAACc;YAC7B,IAAIG,QAAQjB,GAAG,CAAC,0BAA0BC,WAAWiB,kBAAkB,EAAE;YACzEF,GAAGG,MAAM,CAACL,KAAK,aACV3B,wBAAwBsB,cAAcR,WAAWF,SAAS,EAAEM;gBAC/De,aAAa,CAACV,cAAAA,MAAOW,KAAKX,GAAG;;QAEjC;IACF,EAAE,OAAOJ,OAAO;QACdgB,QAAQhB,KAAK,CAAC,+CAA+CA;IAC/D;AACF;AAEA;;;;CAIC,GACD,OAAO,eAAeiB,qCACpBjC,SAAsC,EACtCC,KAA6E;IAE7E,MAAMP,WAAWM,WAAWC,MAAMiC,KAAK,EAAEjC,MAAMQ,SAAS,EACrDoB,MAAM,CAAC;QACNM,QAAQ;QACR,wBAAwBlC,MAAMmC,KAAK;QACnC,wBAAwBnC,MAAMoC,SAAS;QACvCP,aAAa7B,MAAMmC,KAAK;IAC1B,GACCE,KAAK,CAAC,CAACtB;QACNgB,QAAQhB,KAAK,CAAC,0DAA0DA;IAC1E;AACJ"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/mailbox-transport.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 { SecretBoxError } from '@aglyn/shared-util-tools/secret-box'\nimport { createGmailClient } from '../transport/gmail-client'\nimport { createGraphClient } from '../transport/graph-client'\nimport type { TransportDeps } from '../transport/http'\nimport type { OutreachMailClient } from '../transport/mail-client'\nimport {\n mailboxCredentialsRef,\n mailboxRef,\n openMailboxRefreshToken,\n readMailboxCredentials,\n sealMailboxRefreshToken,\n type OutreachStoredMailboxCredentials,\n} from './mailbox-credentials'\nimport {\n readOutreachGoogleConfig,\n readOutreachMicrosoftConfig,\n type OutreachGoogleConfigResult,\n type OutreachMicrosoftConfigResult,\n} from './outreach-config'\n\n/**\n * A CONNECTED MAILBOX, OPENED FOR SENDING (AGL-2978).\n *\n * The door the send and sync runtime, and the panel's test send, reach a\n * mailbox through: the stored credential read, its refresh token opened with\n * the deployment's key, and the transport client for its provider — Gmail,\n * or Microsoft Graph (AGL-3489) — built on it. The token never leaves this\n * function except inside the client.\n *\n * Microsoft rotates refresh tokens; the Graph client hands a rotated one\n * back, and it is sealed and stored in place of the one it replaced.\n *\n * A token opened under an older key of the keyring is sealed again under the\n * current one on the way through, so a key rotation completes itself as\n * mailboxes are used rather than needing a migration.\n */\n\nexport type OpenedOutreachMailbox =\n | { ok: true; client: OutreachMailClient; credential: OutreachStoredMailboxCredentials }\n | {\n ok: false\n /**\n * - `not-configured` — this deployment has no Outreach client or key\n * for the mailbox's provider.\n * - `no-credential` — nothing is stored for the mailbox.\n * - `sealed-token-unreadable` — the stored token does not open with\n * this deployment's key (rotated away, or altered). The mailbox needs\n * connecting again.\n */\n reason: 'not-configured' | 'no-credential' | 'sealed-token-unreadable'\n }\n\nexport interface OpenMailboxDeps extends TransportDeps {\n readConfig?: () => OutreachGoogleConfigResult\n readMicrosoftConfig?: () => OutreachMicrosoftConfigResult\n}\n\nexport async function openOutreachMailboxClient(\n firestore: FirebaseFirestore.Firestore,\n input: { mailboxId: string },\n deps: OpenMailboxDeps = {},\n): Promise<OpenedOutreachMailbox> {\n const snapshot = await mailboxCredentialsRef(firestore, input.mailboxId).get()\n const credential = readMailboxCredentials(snapshot.exists ? snapshot.data() : null)\n if (!credential || credential.mailboxId !== input.mailboxId) {\n const anyConfigured =\n (deps.readConfig ?? readOutreachGoogleConfig)().configured ||\n (deps.readMicrosoftConfig ?? readOutreachMicrosoftConfig)().configured\n return { ok: false, reason: anyConfigured ? 'no-credential' : 'not-configured' }\n }\n const microsoft = credential.provider === 'microsoft' ? (deps.readMicrosoftConfig ?? readOutreachMicrosoftConfig)() : null\n const config = microsoft ?? (deps.readConfig ?? readOutreachGoogleConfig)()\n if (!config.configured) return { ok: false, reason: 'not-configured' }\n let opened: { refreshToken: string; needsReseal: boolean }\n try {\n opened = openMailboxRefreshToken(credential, config.config.keyring)\n } catch (error) {\n if (error instanceof SecretBoxError) return { ok: false, reason: 'sealed-token-unreadable' }\n throw error\n }\n const resealed = opened.needsReseal\n ? await resealMailboxRefreshToken(firestore, credential, opened.refreshToken, config.config.keyring, deps.now)\n : null\n if (microsoft?.configured) {\n const keyring = microsoft.config.keyring\n // The seal the stored document holds now: a rotation replaces it, and\n // the next rotation is guarded against that one.\n let stored = resealed ? { ...credential, ...resealed } : credential\n const client = createGraphClient({\n ...deps,\n clientId: microsoft.config.clientId,\n clientSecret: microsoft.config.clientSecret,\n tenant: microsoft.config.tenant,\n refreshToken: opened.refreshToken,\n selfAddresses: [credential.email],\n onRefreshTokenRotated: async (refreshToken) => {\n const written = await resealMailboxRefreshToken(firestore, stored, refreshToken, keyring, deps.now)\n if (written) stored = { ...stored, ...written }\n },\n })\n return { ok: true, client, credential }\n }\n const client = createGmailClient({\n ...deps,\n clientId: config.config.clientId,\n clientSecret: config.config.clientSecret,\n refreshToken: opened.refreshToken,\n })\n return { ok: true, client, credential }\n}\n\n/**\n * Seals a token — the same one under the current key, or one the provider\n * rotated in — only if the stored value is still the one that was opened: a\n * reconnect that landed in between wrote a newer grant, and that one wins.\n * Best-effort — a failure leaves the old seal, which still opens. Answers\n * what it wrote, or `null` when it wrote nothing.\n */\nasync function resealMailboxRefreshToken(\n firestore: FirebaseFirestore.Firestore,\n credential: Pick<OutreachStoredMailboxCredentials, 'mailboxId' | 'sealedRefreshToken'>,\n refreshToken: string,\n keyring: Parameters<typeof sealMailboxRefreshToken>[2],\n now: (() => number) | undefined,\n): Promise<{ sealedRefreshToken: string; tokenKeyId: string } | null> {\n const ref = mailboxCredentialsRef(firestore, credential.mailboxId)\n try {\n return await firestore.runTransaction(async (tx) => {\n const current = await tx.get(ref)\n if (current.get('sealedRefreshToken') !== credential.sealedRefreshToken) return null\n const sealed = sealMailboxRefreshToken(refreshToken, credential.mailboxId, keyring)\n tx.update(ref, { ...sealed, updatedAtMs: (now ?? Date.now)() })\n return sealed\n })\n } catch (error) {\n console.error('[outreach] resealing a mailbox token failed', error)\n return null\n }\n}\n\n/**\n * Marks a mailbox as needing a reconnect: the provider refused its grant, or\n * its stored token cannot be opened. Nothing sends from it until the member\n * connects it again, which returns it to where it was.\n */\nexport async function markOutreachMailboxReconnectRequired(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; mailboxId: string; errorCode: string; nowMs: number },\n): Promise<void> {\n await mailboxRef(firestore, input.orgId, input.mailboxId)\n .update({\n status: 'reconnect_required',\n 'health.lastErrorAtMs': input.nowMs,\n 'health.lastErrorCode': input.errorCode,\n updatedAtMs: input.nowMs,\n })\n .catch((error: unknown) => {\n console.error('[outreach] marking a mailbox reconnect_required failed', error)\n })\n}\n"],"names":["SecretBoxError","createGmailClient","createGraphClient","mailboxCredentialsRef","mailboxRef","openMailboxRefreshToken","readMailboxCredentials","sealMailboxRefreshToken","readOutreachGoogleConfig","readOutreachMicrosoftConfig","openOutreachMailboxClient","firestore","input","deps","snapshot","mailboxId","get","credential","exists","data","anyConfigured","readConfig","configured","readMicrosoftConfig","ok","reason","microsoft","provider","config","opened","keyring","error","resealed","needsReseal","resealMailboxRefreshToken","refreshToken","now","stored","client","clientId","clientSecret","tenant","selfAddresses","email","onRefreshTokenRotated","written","ref","runTransaction","tx","current","sealedRefreshToken","sealed","update","updatedAtMs","Date","console","markOutreachMailboxReconnectRequired","orgId","status","nowMs","errorCode","catch"],"mappings":"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"}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
+
import type { OutreachMailboxProvider } from '../model/outreach.types';
|
|
17
18
|
/**
|
|
18
19
|
* THE OAUTH `state` FOR A MAILBOX CONNECT (AGL-2978): signed, short-lived,
|
|
19
20
|
* single-use, and bound to one member of one organization.
|
|
@@ -21,11 +22,14 @@
|
|
|
21
22
|
* ## Signed
|
|
22
23
|
*
|
|
23
24
|
* `os1.<payload>.<signature>`, the payload `{ o: orgId, u: uid, n: nonce,
|
|
24
|
-
* e: expiry }` and the signature an HMAC under the shared, fail-closed
|
|
25
|
+
* e: expiry, p: provider }` and the signature an HMAC under the shared, fail-closed
|
|
25
26
|
* `TOKEN_SIGNING_SECRET` with its own `outreach-oauth-state:` context — the
|
|
26
27
|
* construction every other signed token here uses, and domain-separated from
|
|
27
28
|
* all of them. Google hands the state back verbatim, so a state that verifies
|
|
28
|
-
* names the org and the member it was minted for
|
|
29
|
+
* names the org and the member it was minted for, and the provider whose
|
|
30
|
+
* consent screen it went to (AGL-3489) — Google's and Microsoft's both
|
|
31
|
+
* return to the one callback, and the state is what tells them apart. A
|
|
32
|
+
* state with no provider is Google's. The console dispatcher
|
|
29
33
|
* reads exactly that to ask its release gate about the right organization on
|
|
30
34
|
* a redirect that carries no bearer token.
|
|
31
35
|
*
|
|
@@ -65,6 +69,8 @@ export interface OutreachOAuthStateClaims {
|
|
|
65
69
|
nonce: string;
|
|
66
70
|
/** Expiry, epoch ms. */
|
|
67
71
|
exp: number;
|
|
72
|
+
/** The provider the connect went to. */
|
|
73
|
+
provider: OutreachMailboxProvider;
|
|
68
74
|
}
|
|
69
75
|
/**
|
|
70
76
|
* Mints a state for a member's connect. Throws when `TOKEN_SIGNING_SECRET` is
|
|
@@ -74,6 +80,8 @@ export declare function mintOutreachOAuthState(input: {
|
|
|
74
80
|
orgId: string;
|
|
75
81
|
uid: string;
|
|
76
82
|
nowMs: number;
|
|
83
|
+
/** Google when omitted. */
|
|
84
|
+
provider?: OutreachMailboxProvider;
|
|
77
85
|
/** Test seam; a random 32 bytes otherwise. */
|
|
78
86
|
nonce?: string;
|
|
79
87
|
}): {
|
|
@@ -99,7 +107,7 @@ export type OutreachOAuthStateRead = {
|
|
|
99
107
|
export declare function readOutreachOAuthState(state: unknown, nowMs: number): OutreachOAuthStateRead;
|
|
100
108
|
/** The PKCE `code_verifier` for a state's nonce: 43 base64url characters. */
|
|
101
109
|
export declare function outreachPkceVerifier(nonce: string): string;
|
|
102
|
-
/** The OpenID `nonce`
|
|
110
|
+
/** The OpenID `nonce` the provider must echo in the ID token for this connect. */
|
|
103
111
|
export declare function outreachOidcNonce(nonce: string): string;
|
|
104
112
|
/** The pending record's id: one per member per organization. */
|
|
105
113
|
export declare function outreachOAuthStateDocId(orgId: string, uid: string): string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
1
2
|
/**
|
|
2
3
|
* @license
|
|
3
4
|
* Copyright 2026 Aglyn LLC
|
|
@@ -24,11 +25,14 @@ import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
|
24
25
|
* ## Signed
|
|
25
26
|
*
|
|
26
27
|
* `os1.<payload>.<signature>`, the payload `{ o: orgId, u: uid, n: nonce,
|
|
27
|
-
* e: expiry }` and the signature an HMAC under the shared, fail-closed
|
|
28
|
+
* e: expiry, p: provider }` and the signature an HMAC under the shared, fail-closed
|
|
28
29
|
* `TOKEN_SIGNING_SECRET` with its own `outreach-oauth-state:` context — the
|
|
29
30
|
* construction every other signed token here uses, and domain-separated from
|
|
30
31
|
* all of them. Google hands the state back verbatim, so a state that verifies
|
|
31
|
-
* names the org and the member it was minted for
|
|
32
|
+
* names the org and the member it was minted for, and the provider whose
|
|
33
|
+
* consent screen it went to (AGL-3489) — Google's and Microsoft's both
|
|
34
|
+
* return to the one callback, and the state is what tells them apart. A
|
|
35
|
+
* state with no provider is Google's. The console dispatcher
|
|
32
36
|
* reads exactly that to ask its release gate about the right organization on
|
|
33
37
|
* a redirect that carries no bearer token.
|
|
34
38
|
*
|
|
@@ -70,19 +74,22 @@ const hmac = (context, value)=>createHmac('sha256', tokenSigningSecret()).update
|
|
|
70
74
|
* Mints a state for a member's connect. Throws when `TOKEN_SIGNING_SECRET` is
|
|
71
75
|
* not configured; the route answers that as a deployment that cannot connect.
|
|
72
76
|
*/ export function mintOutreachOAuthState(input) {
|
|
73
|
-
var _input_nonce;
|
|
77
|
+
var _input_nonce, _input_provider;
|
|
74
78
|
const claims = {
|
|
75
79
|
orgId: input.orgId,
|
|
76
80
|
uid: input.uid,
|
|
77
81
|
nonce: (_input_nonce = input.nonce) != null ? _input_nonce : randomBytes(32).toString('base64url'),
|
|
78
|
-
exp: input.nowMs + OUTREACH_OAUTH_STATE_TTL_MS
|
|
82
|
+
exp: input.nowMs + OUTREACH_OAUTH_STATE_TTL_MS,
|
|
83
|
+
provider: (_input_provider = input.provider) != null ? _input_provider : 'google'
|
|
79
84
|
};
|
|
80
|
-
const wire = {
|
|
85
|
+
const wire = _extends({
|
|
81
86
|
o: claims.orgId,
|
|
82
87
|
u: claims.uid,
|
|
83
88
|
n: claims.nonce,
|
|
84
89
|
e: claims.exp
|
|
85
|
-
}
|
|
90
|
+
}, claims.provider === 'google' ? {} : {
|
|
91
|
+
p: claims.provider
|
|
92
|
+
});
|
|
86
93
|
const payload = Buffer.from(JSON.stringify(wire), 'utf8').toString('base64url');
|
|
87
94
|
return {
|
|
88
95
|
state: `${STATE_VERSION}.${payload}.${hmac(STATE_CONTEXT, payload)}`,
|
|
@@ -115,8 +122,10 @@ const hmac = (context, value)=>createHmac('sha256', tokenSigningSecret()).update
|
|
|
115
122
|
orgId: typeof (wire == null ? void 0 : wire.o) === 'string' ? wire.o : '',
|
|
116
123
|
uid: typeof (wire == null ? void 0 : wire.u) === 'string' ? wire.u : '',
|
|
117
124
|
nonce: typeof (wire == null ? void 0 : wire.n) === 'string' ? wire.n : '',
|
|
118
|
-
exp: Number(wire == null ? void 0 : wire.e)
|
|
125
|
+
exp: Number(wire == null ? void 0 : wire.e),
|
|
126
|
+
provider: (wire == null ? void 0 : wire.p) === 'microsoft' ? 'microsoft' : 'google'
|
|
119
127
|
};
|
|
128
|
+
if ((wire == null ? void 0 : wire.p) !== undefined && wire.p !== 'microsoft') return invalid;
|
|
120
129
|
if (!claims.orgId || !claims.uid || !claims.nonce || !Number.isFinite(claims.exp)) return invalid;
|
|
121
130
|
if (claims.exp <= nowMs) return {
|
|
122
131
|
ok: false,
|
|
@@ -131,7 +140,7 @@ const hmac = (context, value)=>createHmac('sha256', tokenSigningSecret()).update
|
|
|
131
140
|
/** The PKCE `code_verifier` for a state's nonce: 43 base64url characters. */ export function outreachPkceVerifier(nonce) {
|
|
132
141
|
return hmac(PKCE_CONTEXT, nonce);
|
|
133
142
|
}
|
|
134
|
-
/** The OpenID `nonce`
|
|
143
|
+
/** The OpenID `nonce` the provider must echo in the ID token for this connect. */ export function outreachOidcNonce(nonce) {
|
|
135
144
|
return hmac(OIDC_NONCE_CONTEXT, nonce);
|
|
136
145
|
}
|
|
137
146
|
/** The pending record's id: one per member per organization. */ export function outreachOAuthStateDocId(orgId, uid) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/oauth-state.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 { consumeOnce } from '@aglyn/tenant-data-admin/server/consume-once'\nimport { tokenSigningSecret } from '@aglyn/tenant-data-admin/server/media-signing'\nimport { safeEqual } from '@aglyn/tenant-data-admin/server/safe-equal'\nimport { createHash, createHmac, randomBytes } from 'node:crypto'\n\n/**\n * THE OAUTH `state` FOR A MAILBOX CONNECT (AGL-2978): signed, short-lived,\n * single-use, and bound to one member of one organization.\n *\n * ## Signed\n *\n * `os1.<payload>.<signature>`, the payload `{ o: orgId, u: uid, n: nonce,\n * e: expiry }` and the signature an HMAC under the shared, fail-closed\n * `TOKEN_SIGNING_SECRET` with its own `outreach-oauth-state:` context — the\n * construction every other signed token here uses, and domain-separated from\n * all of them. Google hands the state back verbatim, so a state that verifies\n * names the org and the member it was minted for. The console dispatcher\n * reads exactly that to ask its release gate about the right organization on\n * a redirect that carries no bearer token.\n *\n * ## Single-use\n *\n * A signature proves who minted a state, not that it is fresh. Each connect\n * also writes ONE pending record per member per organization, at\n * `orgs/{orgId}/outreachOAuthStates/{sha256(orgId, uid)}`, holding the\n * SHA-256 of the state's nonce, its expiry and the redirect address the code\n * was issued for. Finishing a connect consumes that record in a transaction\n * (`consumeOnce`), so a second use of the same state finds nothing, and\n * starting another connect replaces the record, retiring every earlier state.\n *\n * ## Under the organization\n *\n * The record is the organization's, so it lives in the organization's tree:\n * the workspace erasure's recursive delete and the workspace export's walk\n * reach it with no sweep of their own, and nothing outside this plugin names\n * the collection. No Firestore rule names it either, and the org block has no\n * catch-all, so every client is refused it — a client that could write one\n * could re-arm a consumed state, and one that could read one would learn\n * which members have a connect open.\n *\n * ## Derived, not stored\n *\n * The PKCE verifier and the OpenID `nonce` are HMACs of the state's nonce\n * under the same secret, each with its own context. Nothing secret is stored\n * for them, and only this server can recompute either — a state copied off a\n * redirect is not enough to redeem its code.\n */\n\nexport const OUTREACH_OAUTH_STATES_COLLECTION = 'outreachOAuthStates'\n\n/** How long a rep has to finish Google's consent screen. */\nexport const OUTREACH_OAUTH_STATE_TTL_MS = 10 * 60 * 1000\n\nconst STATE_VERSION = 'os1'\nconst STATE_CONTEXT = 'outreach-oauth-state:v1'\nconst PKCE_CONTEXT = 'outreach-oauth-pkce:v1'\nconst OIDC_NONCE_CONTEXT = 'outreach-oauth-oidc-nonce:v1'\nconst STATE_MAX_CHARS = 1024\n\nexport interface OutreachOAuthStateClaims {\n orgId: string\n uid: string\n nonce: string\n /** Expiry, epoch ms. */\n exp: number\n}\n\ninterface WireClaims {\n o: string\n u: string\n n: string\n e: number\n}\n\nconst hmac = (context: string, value: string): string =>\n createHmac('sha256', tokenSigningSecret()).update(`${context}:${value}`).digest('base64url')\n\n/**\n * Mints a state for a member's connect. Throws when `TOKEN_SIGNING_SECRET` is\n * not configured; the route answers that as a deployment that cannot connect.\n */\nexport function mintOutreachOAuthState(input: {\n orgId: string\n uid: string\n nowMs: number\n /** Test seam; a random 32 bytes otherwise. */\n nonce?: string\n}): { state: string; claims: OutreachOAuthStateClaims } {\n const claims: OutreachOAuthStateClaims = {\n orgId: input.orgId,\n uid: input.uid,\n nonce: input.nonce ?? randomBytes(32).toString('base64url'),\n exp: input.nowMs + OUTREACH_OAUTH_STATE_TTL_MS,\n }\n const wire: WireClaims = { o: claims.orgId, u: claims.uid, n: claims.nonce, e: claims.exp }\n const payload = Buffer.from(JSON.stringify(wire), 'utf8').toString('base64url')\n return { state: `${STATE_VERSION}.${payload}.${hmac(STATE_CONTEXT, payload)}`, claims }\n}\n\nexport type OutreachOAuthStateRead =\n | { ok: true; claims: OutreachOAuthStateClaims }\n /** Authentic and past its expiry — the claims can still say whose it was. */\n | { ok: false; refusal: 'state-expired'; claims: OutreachOAuthStateClaims }\n /** Tampered, truncated, another version, or no secret to check it with. */\n | { ok: false; refusal: 'state-invalid' }\n\n/** Verifies a state's signature and expiry. Never throws. */\nexport function readOutreachOAuthState(state: unknown, nowMs: number): OutreachOAuthStateRead {\n const invalid = { ok: false, refusal: 'state-invalid' } as const\n if (typeof state !== 'string' || !state || state.length > STATE_MAX_CHARS) return invalid\n const parts = state.split('.')\n if (parts.length !== 3 || parts[0] !== STATE_VERSION) return invalid\n const [, payload, signature] = parts\n let expected: string\n try {\n expected = hmac(STATE_CONTEXT, payload)\n } catch {\n return invalid\n }\n if (!safeEqual(signature, expected)) return invalid\n let wire: Partial<WireClaims>\n try {\n wire = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Partial<WireClaims>\n } catch {\n return invalid\n }\n const claims: OutreachOAuthStateClaims = {\n orgId: typeof wire?.o === 'string' ? wire.o : '',\n uid: typeof wire?.u === 'string' ? wire.u : '',\n nonce: typeof wire?.n === 'string' ? wire.n : '',\n exp: Number(wire?.e),\n }\n if (!claims.orgId || !claims.uid || !claims.nonce || !Number.isFinite(claims.exp)) return invalid\n if (claims.exp <= nowMs) return { ok: false, refusal: 'state-expired', claims }\n return { ok: true, claims }\n}\n\n/** The PKCE `code_verifier` for a state's nonce: 43 base64url characters. */\nexport function outreachPkceVerifier(nonce: string): string {\n return hmac(PKCE_CONTEXT, nonce)\n}\n\n/** The OpenID `nonce` Google must echo in the ID token for this connect. */\nexport function outreachOidcNonce(nonce: string): string {\n return hmac(OIDC_NONCE_CONTEXT, nonce)\n}\n\n/** The pending record's id: one per member per organization. */\nexport function outreachOAuthStateDocId(orgId: string, uid: string): string {\n return createHash('sha256').update(`${orgId}\\n${uid}`).digest('hex')\n}\n\n/** `orgs/{orgId}/outreachOAuthStates/{id}` for a member's pending connect. */\nexport function outreachOAuthStateRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(OUTREACH_OAUTH_STATES_COLLECTION)\n .doc(outreachOAuthStateDocId(orgId, uid))\n}\n\nconst digest = (nonce: string) => createHash('sha256').update(nonce).digest('base64url')\n\n/** `orgs/{orgId}/outreachOAuthStates/{id}` — see the module comment. */\nexport interface OutreachOAuthStateRecord {\n orgId: string\n uid: string\n /** SHA-256 of the state's nonce; the nonce itself is never stored. */\n nonceDigest: string\n /** The redirect address the authorization code was issued for. */\n redirectUri: string\n expiresAtMs: number\n createdAtMs: number\n}\n\n/**\n * Writes the pending record for a freshly minted state, replacing any earlier\n * one for the same member and organization.\n */\nexport async function recordOutreachOAuthState(\n firestore: FirebaseFirestore.Firestore,\n input: { claims: OutreachOAuthStateClaims; redirectUri: string; nowMs: number },\n): Promise<void> {\n const { claims } = input\n const record: OutreachOAuthStateRecord = {\n orgId: claims.orgId,\n uid: claims.uid,\n nonceDigest: digest(claims.nonce),\n redirectUri: input.redirectUri,\n expiresAtMs: claims.exp,\n createdAtMs: input.nowMs,\n }\n await outreachOAuthStateRef(firestore, claims.orgId, claims.uid).set(record)\n}\n\nexport type OutreachOAuthStateConsumed =\n | { ok: true; redirectUri: string }\n /** No pending record: this state was already used, or never recorded. */\n | { ok: false; refusal: 'state-replayed' }\n /** A later connect by the same member replaced this state. */\n | { ok: false; refusal: 'state-superseded' }\n | { ok: false; refusal: 'state-expired' }\n\n/**\n * Consumes the pending record a verified state names — once. A superseded\n * state leaves the newer record standing for the connect that owns it.\n */\nexport async function consumeOutreachOAuthState(\n firestore: FirebaseFirestore.Firestore,\n input: { claims: OutreachOAuthStateClaims; nowMs: number },\n): Promise<OutreachOAuthStateConsumed> {\n const { claims } = input\n const ref = outreachOAuthStateRef(firestore, claims.orgId, claims.uid)\n const result = await consumeOnce<string>(firestore, ref, (data) => {\n if (data['orgId'] !== claims.orgId || data['uid'] !== claims.uid) {\n return { accept: false, reason: 'state-superseded' }\n }\n if (!safeEqual(String(data['nonceDigest'] ?? ''), digest(claims.nonce))) {\n return { accept: false, reason: 'state-superseded' }\n }\n if (Number(data['expiresAtMs']) <= input.nowMs) {\n // Nobody can accept an expired record, so removing it destroys nothing\n // a concurrent consume could have used.\n return { accept: false, reason: 'state-expired', remove: true }\n }\n return { accept: true, value: String(data['redirectUri'] ?? ''), remove: true }\n })\n if (result.ok && result.value) return { ok: true, redirectUri: result.value }\n if (result.reason === 'state-superseded') return { ok: false, refusal: 'state-superseded' }\n if (result.reason === 'state-expired') return { ok: false, refusal: 'state-expired' }\n return { ok: false, refusal: 'state-replayed' }\n}\n"],"names":["consumeOnce","tokenSigningSecret","safeEqual","createHash","createHmac","randomBytes","OUTREACH_OAUTH_STATES_COLLECTION","OUTREACH_OAUTH_STATE_TTL_MS","STATE_VERSION","STATE_CONTEXT","PKCE_CONTEXT","OIDC_NONCE_CONTEXT","STATE_MAX_CHARS","hmac","context","value","update","digest","mintOutreachOAuthState","input","claims","orgId","uid","nonce","toString","exp","nowMs","wire","o","u","n","e","payload","Buffer","from","JSON","stringify","state","readOutreachOAuthState","invalid","ok","refusal","length","parts","split","signature","expected","parse","Number","isFinite","outreachPkceVerifier","outreachOidcNonce","outreachOAuthStateDocId","outreachOAuthStateRef","firestore","collection","doc","recordOutreachOAuthState","record","nonceDigest","redirectUri","expiresAtMs","createdAtMs","set","consumeOutreachOAuthState","ref","result","data","accept","reason","String","remove"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/oauth-state.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 { consumeOnce } from '@aglyn/tenant-data-admin/server/consume-once'\nimport { tokenSigningSecret } from '@aglyn/tenant-data-admin/server/media-signing'\nimport { safeEqual } from '@aglyn/tenant-data-admin/server/safe-equal'\nimport { createHash, createHmac, randomBytes } from 'node:crypto'\nimport type { OutreachMailboxProvider } from '../model/outreach.types'\n\n/**\n * THE OAUTH `state` FOR A MAILBOX CONNECT (AGL-2978): signed, short-lived,\n * single-use, and bound to one member of one organization.\n *\n * ## Signed\n *\n * `os1.<payload>.<signature>`, the payload `{ o: orgId, u: uid, n: nonce,\n * e: expiry, p: provider }` and the signature an HMAC under the shared, fail-closed\n * `TOKEN_SIGNING_SECRET` with its own `outreach-oauth-state:` context — the\n * construction every other signed token here uses, and domain-separated from\n * all of them. Google hands the state back verbatim, so a state that verifies\n * names the org and the member it was minted for, and the provider whose\n * consent screen it went to (AGL-3489) — Google's and Microsoft's both\n * return to the one callback, and the state is what tells them apart. A\n * state with no provider is Google's. The console dispatcher\n * reads exactly that to ask its release gate about the right organization on\n * a redirect that carries no bearer token.\n *\n * ## Single-use\n *\n * A signature proves who minted a state, not that it is fresh. Each connect\n * also writes ONE pending record per member per organization, at\n * `orgs/{orgId}/outreachOAuthStates/{sha256(orgId, uid)}`, holding the\n * SHA-256 of the state's nonce, its expiry and the redirect address the code\n * was issued for. Finishing a connect consumes that record in a transaction\n * (`consumeOnce`), so a second use of the same state finds nothing, and\n * starting another connect replaces the record, retiring every earlier state.\n *\n * ## Under the organization\n *\n * The record is the organization's, so it lives in the organization's tree:\n * the workspace erasure's recursive delete and the workspace export's walk\n * reach it with no sweep of their own, and nothing outside this plugin names\n * the collection. No Firestore rule names it either, and the org block has no\n * catch-all, so every client is refused it — a client that could write one\n * could re-arm a consumed state, and one that could read one would learn\n * which members have a connect open.\n *\n * ## Derived, not stored\n *\n * The PKCE verifier and the OpenID `nonce` are HMACs of the state's nonce\n * under the same secret, each with its own context. Nothing secret is stored\n * for them, and only this server can recompute either — a state copied off a\n * redirect is not enough to redeem its code.\n */\n\nexport const OUTREACH_OAUTH_STATES_COLLECTION = 'outreachOAuthStates'\n\n/** How long a rep has to finish Google's consent screen. */\nexport const OUTREACH_OAUTH_STATE_TTL_MS = 10 * 60 * 1000\n\nconst STATE_VERSION = 'os1'\nconst STATE_CONTEXT = 'outreach-oauth-state:v1'\nconst PKCE_CONTEXT = 'outreach-oauth-pkce:v1'\nconst OIDC_NONCE_CONTEXT = 'outreach-oauth-oidc-nonce:v1'\nconst STATE_MAX_CHARS = 1024\n\nexport interface OutreachOAuthStateClaims {\n orgId: string\n uid: string\n nonce: string\n /** Expiry, epoch ms. */\n exp: number\n /** The provider the connect went to. */\n provider: OutreachMailboxProvider\n}\n\ninterface WireClaims {\n o: string\n u: string\n n: string\n e: number\n p?: string\n}\n\nconst hmac = (context: string, value: string): string =>\n createHmac('sha256', tokenSigningSecret()).update(`${context}:${value}`).digest('base64url')\n\n/**\n * Mints a state for a member's connect. Throws when `TOKEN_SIGNING_SECRET` is\n * not configured; the route answers that as a deployment that cannot connect.\n */\nexport function mintOutreachOAuthState(input: {\n orgId: string\n uid: string\n nowMs: number\n /** Google when omitted. */\n provider?: OutreachMailboxProvider\n /** Test seam; a random 32 bytes otherwise. */\n nonce?: string\n}): { state: string; claims: OutreachOAuthStateClaims } {\n const claims: OutreachOAuthStateClaims = {\n orgId: input.orgId,\n uid: input.uid,\n nonce: input.nonce ?? randomBytes(32).toString('base64url'),\n exp: input.nowMs + OUTREACH_OAUTH_STATE_TTL_MS,\n provider: input.provider ?? 'google',\n }\n const wire: WireClaims = {\n o: claims.orgId,\n u: claims.uid,\n n: claims.nonce,\n e: claims.exp,\n ...(claims.provider === 'google' ? {} : { p: claims.provider }),\n }\n const payload = Buffer.from(JSON.stringify(wire), 'utf8').toString('base64url')\n return { state: `${STATE_VERSION}.${payload}.${hmac(STATE_CONTEXT, payload)}`, claims }\n}\n\nexport type OutreachOAuthStateRead =\n | { ok: true; claims: OutreachOAuthStateClaims }\n /** Authentic and past its expiry — the claims can still say whose it was. */\n | { ok: false; refusal: 'state-expired'; claims: OutreachOAuthStateClaims }\n /** Tampered, truncated, another version, or no secret to check it with. */\n | { ok: false; refusal: 'state-invalid' }\n\n/** Verifies a state's signature and expiry. Never throws. */\nexport function readOutreachOAuthState(state: unknown, nowMs: number): OutreachOAuthStateRead {\n const invalid = { ok: false, refusal: 'state-invalid' } as const\n if (typeof state !== 'string' || !state || state.length > STATE_MAX_CHARS) return invalid\n const parts = state.split('.')\n if (parts.length !== 3 || parts[0] !== STATE_VERSION) return invalid\n const [, payload, signature] = parts\n let expected: string\n try {\n expected = hmac(STATE_CONTEXT, payload)\n } catch {\n return invalid\n }\n if (!safeEqual(signature, expected)) return invalid\n let wire: Partial<WireClaims>\n try {\n wire = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Partial<WireClaims>\n } catch {\n return invalid\n }\n const claims: OutreachOAuthStateClaims = {\n orgId: typeof wire?.o === 'string' ? wire.o : '',\n uid: typeof wire?.u === 'string' ? wire.u : '',\n nonce: typeof wire?.n === 'string' ? wire.n : '',\n exp: Number(wire?.e),\n provider: wire?.p === 'microsoft' ? 'microsoft' : 'google',\n }\n if (wire?.p !== undefined && wire.p !== 'microsoft') return invalid\n if (!claims.orgId || !claims.uid || !claims.nonce || !Number.isFinite(claims.exp)) return invalid\n if (claims.exp <= nowMs) return { ok: false, refusal: 'state-expired', claims }\n return { ok: true, claims }\n}\n\n/** The PKCE `code_verifier` for a state's nonce: 43 base64url characters. */\nexport function outreachPkceVerifier(nonce: string): string {\n return hmac(PKCE_CONTEXT, nonce)\n}\n\n/** The OpenID `nonce` the provider must echo in the ID token for this connect. */\nexport function outreachOidcNonce(nonce: string): string {\n return hmac(OIDC_NONCE_CONTEXT, nonce)\n}\n\n/** The pending record's id: one per member per organization. */\nexport function outreachOAuthStateDocId(orgId: string, uid: string): string {\n return createHash('sha256').update(`${orgId}\\n${uid}`).digest('hex')\n}\n\n/** `orgs/{orgId}/outreachOAuthStates/{id}` for a member's pending connect. */\nexport function outreachOAuthStateRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(OUTREACH_OAUTH_STATES_COLLECTION)\n .doc(outreachOAuthStateDocId(orgId, uid))\n}\n\nconst digest = (nonce: string) => createHash('sha256').update(nonce).digest('base64url')\n\n/** `orgs/{orgId}/outreachOAuthStates/{id}` — see the module comment. */\nexport interface OutreachOAuthStateRecord {\n orgId: string\n uid: string\n /** SHA-256 of the state's nonce; the nonce itself is never stored. */\n nonceDigest: string\n /** The redirect address the authorization code was issued for. */\n redirectUri: string\n expiresAtMs: number\n createdAtMs: number\n}\n\n/**\n * Writes the pending record for a freshly minted state, replacing any earlier\n * one for the same member and organization.\n */\nexport async function recordOutreachOAuthState(\n firestore: FirebaseFirestore.Firestore,\n input: { claims: OutreachOAuthStateClaims; redirectUri: string; nowMs: number },\n): Promise<void> {\n const { claims } = input\n const record: OutreachOAuthStateRecord = {\n orgId: claims.orgId,\n uid: claims.uid,\n nonceDigest: digest(claims.nonce),\n redirectUri: input.redirectUri,\n expiresAtMs: claims.exp,\n createdAtMs: input.nowMs,\n }\n await outreachOAuthStateRef(firestore, claims.orgId, claims.uid).set(record)\n}\n\nexport type OutreachOAuthStateConsumed =\n | { ok: true; redirectUri: string }\n /** No pending record: this state was already used, or never recorded. */\n | { ok: false; refusal: 'state-replayed' }\n /** A later connect by the same member replaced this state. */\n | { ok: false; refusal: 'state-superseded' }\n | { ok: false; refusal: 'state-expired' }\n\n/**\n * Consumes the pending record a verified state names — once. A superseded\n * state leaves the newer record standing for the connect that owns it.\n */\nexport async function consumeOutreachOAuthState(\n firestore: FirebaseFirestore.Firestore,\n input: { claims: OutreachOAuthStateClaims; nowMs: number },\n): Promise<OutreachOAuthStateConsumed> {\n const { claims } = input\n const ref = outreachOAuthStateRef(firestore, claims.orgId, claims.uid)\n const result = await consumeOnce<string>(firestore, ref, (data) => {\n if (data['orgId'] !== claims.orgId || data['uid'] !== claims.uid) {\n return { accept: false, reason: 'state-superseded' }\n }\n if (!safeEqual(String(data['nonceDigest'] ?? ''), digest(claims.nonce))) {\n return { accept: false, reason: 'state-superseded' }\n }\n if (Number(data['expiresAtMs']) <= input.nowMs) {\n // Nobody can accept an expired record, so removing it destroys nothing\n // a concurrent consume could have used.\n return { accept: false, reason: 'state-expired', remove: true }\n }\n return { accept: true, value: String(data['redirectUri'] ?? ''), remove: true }\n })\n if (result.ok && result.value) return { ok: true, redirectUri: result.value }\n if (result.reason === 'state-superseded') return { ok: false, refusal: 'state-superseded' }\n if (result.reason === 'state-expired') return { ok: false, refusal: 'state-expired' }\n return { ok: false, refusal: 'state-replayed' }\n}\n"],"names":["consumeOnce","tokenSigningSecret","safeEqual","createHash","createHmac","randomBytes","OUTREACH_OAUTH_STATES_COLLECTION","OUTREACH_OAUTH_STATE_TTL_MS","STATE_VERSION","STATE_CONTEXT","PKCE_CONTEXT","OIDC_NONCE_CONTEXT","STATE_MAX_CHARS","hmac","context","value","update","digest","mintOutreachOAuthState","input","claims","orgId","uid","nonce","toString","exp","nowMs","provider","wire","o","u","n","e","p","payload","Buffer","from","JSON","stringify","state","readOutreachOAuthState","invalid","ok","refusal","length","parts","split","signature","expected","parse","Number","undefined","isFinite","outreachPkceVerifier","outreachOidcNonce","outreachOAuthStateDocId","outreachOAuthStateRef","firestore","collection","doc","recordOutreachOAuthState","record","nonceDigest","redirectUri","expiresAtMs","createdAtMs","set","consumeOutreachOAuthState","ref","result","data","accept","reason","String","remove"],"mappings":"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"}
|
|
@@ -15,12 +15,17 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
17
|
import { type SecretBoxKeyring } from '@aglyn/shared-util-tools/secret-box';
|
|
18
|
+
import type { OutreachMailboxProvider } from '../model/outreach.types';
|
|
18
19
|
/**
|
|
19
20
|
* THE ONE MODULE THAT READS OUTREACH'S CREDENTIALS FROM THE ENVIRONMENT
|
|
20
21
|
* (AGL-2978).
|
|
21
22
|
*
|
|
22
23
|
* - `GOOGLE_OUTREACH_CLIENT_ID` and `GOOGLE_OUTREACH_CLIENT_SECRET` — the
|
|
23
24
|
* OAuth client a rep's Google account grants mailbox access to.
|
|
25
|
+
* - `MICROSOFT_OUTREACH_CLIENT_ID` and `MICROSOFT_OUTREACH_CLIENT_SECRET` —
|
|
26
|
+
* the Microsoft Entra app registration a rep's Microsoft 365 account
|
|
27
|
+
* grants mailbox access to (AGL-3489), and `MICROSOFT_OUTREACH_TENANT`,
|
|
28
|
+
* which tenants may sign in to it: `common` when unset.
|
|
24
29
|
* - `OUTREACH_TOKEN_KEY` — 32 random bytes, base64, sealing every stored
|
|
25
30
|
* refresh token. A comma-separated list rotates: the first key seals, the
|
|
26
31
|
* rest only open, and a token opened under an older key is sealed again
|
|
@@ -44,7 +49,12 @@ export declare const OUTREACH_ENV: {
|
|
|
44
49
|
readonly clientId: "GOOGLE_OUTREACH_CLIENT_ID";
|
|
45
50
|
readonly clientSecret: "GOOGLE_OUTREACH_CLIENT_SECRET";
|
|
46
51
|
readonly tokenKey: "OUTREACH_TOKEN_KEY";
|
|
52
|
+
readonly microsoftClientId: "MICROSOFT_OUTREACH_CLIENT_ID";
|
|
53
|
+
readonly microsoftClientSecret: "MICROSOFT_OUTREACH_CLIENT_SECRET";
|
|
54
|
+
readonly microsoftTenant: "MICROSOFT_OUTREACH_TENANT";
|
|
47
55
|
};
|
|
56
|
+
/** The tenant a Microsoft connect signs in through when none is named. */
|
|
57
|
+
export declare const OUTREACH_MICROSOFT_DEFAULT_TENANT = "common";
|
|
48
58
|
export interface OutreachGoogleConfig {
|
|
49
59
|
clientId: string;
|
|
50
60
|
clientSecret: string;
|
|
@@ -58,7 +68,27 @@ export type OutreachGoogleConfigResult = {
|
|
|
58
68
|
/** The env var names that are unset or unusable — never their values. */
|
|
59
69
|
missing: string[];
|
|
60
70
|
};
|
|
71
|
+
export interface OutreachMicrosoftConfig extends OutreachGoogleConfig {
|
|
72
|
+
/** The tenant segment of the identity platform's endpoints. */
|
|
73
|
+
tenant: string;
|
|
74
|
+
}
|
|
75
|
+
export type OutreachMicrosoftConfigResult = {
|
|
76
|
+
configured: true;
|
|
77
|
+
config: OutreachMicrosoftConfig;
|
|
78
|
+
} | {
|
|
79
|
+
configured: false;
|
|
80
|
+
missing: string[];
|
|
81
|
+
};
|
|
61
82
|
/** The sentence every route and the panel use for an unconfigured deployment. */
|
|
62
83
|
export declare const OUTREACH_NOT_CONFIGURED_MESSAGE = "Connecting a Google mailbox is not configured on this deployment.";
|
|
84
|
+
/** The same sentence for a Microsoft connect (AGL-3489). */
|
|
85
|
+
export declare const OUTREACH_MICROSOFT_NOT_CONFIGURED_MESSAGE = "Connecting a Microsoft 365 mailbox is not configured on this deployment.";
|
|
86
|
+
/** The not-configured sentence for a provider. */
|
|
87
|
+
export declare function outreachNotConfiguredMessage(provider: OutreachMailboxProvider): string;
|
|
63
88
|
/** Reads the three variables. Never throws; an unusable key counts as missing. */
|
|
64
89
|
export declare function readOutreachGoogleConfig(): OutreachGoogleConfigResult;
|
|
90
|
+
/**
|
|
91
|
+
* Reads the Microsoft variables and the shared token key. Never throws; a
|
|
92
|
+
* tenant that is not one the identity platform addresses counts as missing.
|
|
93
|
+
*/
|
|
94
|
+
export declare function readOutreachMicrosoftConfig(): OutreachMicrosoftConfigResult;
|
|
@@ -14,12 +14,17 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ import { parseSecretBoxKeyring } from "@aglyn/shared-util-tools/secret-box";
|
|
17
|
+
import { isMicrosoftTenant } from "../transport/microsoft-oauth.js";
|
|
17
18
|
/**
|
|
18
19
|
* THE ONE MODULE THAT READS OUTREACH'S CREDENTIALS FROM THE ENVIRONMENT
|
|
19
20
|
* (AGL-2978).
|
|
20
21
|
*
|
|
21
22
|
* - `GOOGLE_OUTREACH_CLIENT_ID` and `GOOGLE_OUTREACH_CLIENT_SECRET` — the
|
|
22
23
|
* OAuth client a rep's Google account grants mailbox access to.
|
|
24
|
+
* - `MICROSOFT_OUTREACH_CLIENT_ID` and `MICROSOFT_OUTREACH_CLIENT_SECRET` —
|
|
25
|
+
* the Microsoft Entra app registration a rep's Microsoft 365 account
|
|
26
|
+
* grants mailbox access to (AGL-3489), and `MICROSOFT_OUTREACH_TENANT`,
|
|
27
|
+
* which tenants may sign in to it: `common` when unset.
|
|
23
28
|
* - `OUTREACH_TOKEN_KEY` — 32 random bytes, base64, sealing every stored
|
|
24
29
|
* refresh token. A comma-separated list rotates: the first key seals, the
|
|
25
30
|
* rest only open, and a token opened under an older key is sealed again
|
|
@@ -40,25 +45,62 @@
|
|
|
40
45
|
*/ /** The env vars, named once for the refusal that lists what is missing. */ export const OUTREACH_ENV = {
|
|
41
46
|
clientId: 'GOOGLE_OUTREACH_CLIENT_ID',
|
|
42
47
|
clientSecret: 'GOOGLE_OUTREACH_CLIENT_SECRET',
|
|
43
|
-
tokenKey: 'OUTREACH_TOKEN_KEY'
|
|
48
|
+
tokenKey: 'OUTREACH_TOKEN_KEY',
|
|
49
|
+
microsoftClientId: 'MICROSOFT_OUTREACH_CLIENT_ID',
|
|
50
|
+
microsoftClientSecret: 'MICROSOFT_OUTREACH_CLIENT_SECRET',
|
|
51
|
+
microsoftTenant: 'MICROSOFT_OUTREACH_TENANT'
|
|
44
52
|
};
|
|
53
|
+
/** The tenant a Microsoft connect signs in through when none is named. */ export const OUTREACH_MICROSOFT_DEFAULT_TENANT = 'common';
|
|
45
54
|
/** The sentence every route and the panel use for an unconfigured deployment. */ export const OUTREACH_NOT_CONFIGURED_MESSAGE = 'Connecting a Google mailbox is not configured on this deployment.';
|
|
55
|
+
/** The same sentence for a Microsoft connect (AGL-3489). */ export const OUTREACH_MICROSOFT_NOT_CONFIGURED_MESSAGE = 'Connecting a Microsoft 365 mailbox is not configured on this deployment.';
|
|
56
|
+
/** The not-configured sentence for a provider. */ export function outreachNotConfiguredMessage(provider) {
|
|
57
|
+
return provider === 'microsoft' ? OUTREACH_MICROSOFT_NOT_CONFIGURED_MESSAGE : OUTREACH_NOT_CONFIGURED_MESSAGE;
|
|
58
|
+
}
|
|
59
|
+
/** The shared token key, or `null` when it is unset or unusable. */ function readTokenKeyring() {
|
|
60
|
+
var _process_env_OUTREACH_TOKEN_KEY;
|
|
61
|
+
const tokenKey = String((_process_env_OUTREACH_TOKEN_KEY = process.env['OUTREACH_TOKEN_KEY']) != null ? _process_env_OUTREACH_TOKEN_KEY : '').trim();
|
|
62
|
+
if (!tokenKey) return null;
|
|
63
|
+
try {
|
|
64
|
+
return parseSecretBoxKeyring(tokenKey);
|
|
65
|
+
} catch (unused) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
46
69
|
/** Reads the three variables. Never throws; an unusable key counts as missing. */ export function readOutreachGoogleConfig() {
|
|
47
|
-
var _process_env_GOOGLE_OUTREACH_CLIENT_ID, _process_env_GOOGLE_OUTREACH_CLIENT_SECRET
|
|
70
|
+
var _process_env_GOOGLE_OUTREACH_CLIENT_ID, _process_env_GOOGLE_OUTREACH_CLIENT_SECRET;
|
|
48
71
|
const clientId = String((_process_env_GOOGLE_OUTREACH_CLIENT_ID = process.env['GOOGLE_OUTREACH_CLIENT_ID']) != null ? _process_env_GOOGLE_OUTREACH_CLIENT_ID : '').trim();
|
|
49
72
|
const clientSecret = String((_process_env_GOOGLE_OUTREACH_CLIENT_SECRET = process.env['GOOGLE_OUTREACH_CLIENT_SECRET']) != null ? _process_env_GOOGLE_OUTREACH_CLIENT_SECRET : '').trim();
|
|
50
|
-
const tokenKey = String((_process_env_OUTREACH_TOKEN_KEY = process.env['OUTREACH_TOKEN_KEY']) != null ? _process_env_OUTREACH_TOKEN_KEY : '').trim();
|
|
51
73
|
const missing = [];
|
|
52
74
|
if (!clientId) missing.push(OUTREACH_ENV.clientId);
|
|
53
75
|
if (!clientSecret) missing.push(OUTREACH_ENV.clientSecret);
|
|
54
|
-
|
|
55
|
-
if (tokenKey)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
76
|
+
const keyring = readTokenKeyring();
|
|
77
|
+
if (!keyring) missing.push(OUTREACH_ENV.tokenKey);
|
|
78
|
+
if (missing.length || !keyring) return {
|
|
79
|
+
configured: false,
|
|
80
|
+
missing
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
configured: true,
|
|
84
|
+
config: {
|
|
85
|
+
clientId,
|
|
86
|
+
clientSecret,
|
|
87
|
+
keyring
|
|
60
88
|
}
|
|
61
|
-
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Reads the Microsoft variables and the shared token key. Never throws; a
|
|
93
|
+
* tenant that is not one the identity platform addresses counts as missing.
|
|
94
|
+
*/ export function readOutreachMicrosoftConfig() {
|
|
95
|
+
var _process_env_MICROSOFT_OUTREACH_CLIENT_ID, _process_env_MICROSOFT_OUTREACH_CLIENT_SECRET, _process_env_MICROSOFT_OUTREACH_TENANT;
|
|
96
|
+
const clientId = String((_process_env_MICROSOFT_OUTREACH_CLIENT_ID = process.env['MICROSOFT_OUTREACH_CLIENT_ID']) != null ? _process_env_MICROSOFT_OUTREACH_CLIENT_ID : '').trim();
|
|
97
|
+
const clientSecret = String((_process_env_MICROSOFT_OUTREACH_CLIENT_SECRET = process.env['MICROSOFT_OUTREACH_CLIENT_SECRET']) != null ? _process_env_MICROSOFT_OUTREACH_CLIENT_SECRET : '').trim();
|
|
98
|
+
const tenant = String((_process_env_MICROSOFT_OUTREACH_TENANT = process.env['MICROSOFT_OUTREACH_TENANT']) != null ? _process_env_MICROSOFT_OUTREACH_TENANT : '').trim() || OUTREACH_MICROSOFT_DEFAULT_TENANT;
|
|
99
|
+
const missing = [];
|
|
100
|
+
if (!clientId) missing.push(OUTREACH_ENV.microsoftClientId);
|
|
101
|
+
if (!clientSecret) missing.push(OUTREACH_ENV.microsoftClientSecret);
|
|
102
|
+
if (!isMicrosoftTenant(tenant)) missing.push(OUTREACH_ENV.microsoftTenant);
|
|
103
|
+
const keyring = readTokenKeyring();
|
|
62
104
|
if (!keyring) missing.push(OUTREACH_ENV.tokenKey);
|
|
63
105
|
if (missing.length || !keyring) return {
|
|
64
106
|
configured: false,
|
|
@@ -69,6 +111,7 @@
|
|
|
69
111
|
config: {
|
|
70
112
|
clientId,
|
|
71
113
|
clientSecret,
|
|
114
|
+
tenant,
|
|
72
115
|
keyring
|
|
73
116
|
}
|
|
74
117
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/outreach-config.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 parseSecretBoxKeyring,\n type SecretBoxKeyring,\n} from '@aglyn/shared-util-tools/secret-box'\n\n/**\n * THE ONE MODULE THAT READS OUTREACH'S CREDENTIALS FROM THE ENVIRONMENT\n * (AGL-2978).\n *\n * - `GOOGLE_OUTREACH_CLIENT_ID` and `GOOGLE_OUTREACH_CLIENT_SECRET` — the\n * OAuth client a rep's Google account grants mailbox access to.\n * - `OUTREACH_TOKEN_KEY` — 32 random bytes, base64, sealing every stored\n * refresh token. A comma-separated list rotates: the first key seals, the\n * rest only open, and a token opened under an older key is sealed again\n * under the first.\n *\n * ## Console-only, structurally\n *\n * The client secret and the token key together are the power to send as\n * every connected rep, so neither may be reachable from the tenant runtime,\n * which serves the public internet. They are read here and nowhere else; this\n * module is imported only by Outreach's server half and its console-only\n * declarations; and those are loaded only by the console's server manifests.\n * `outreach-credential-isolation.spec.ts`, beside this plugin's other specs,\n * holds all three facts, the way `sending-domain-credential-isolation.spec.ts`\n * holds the Resend domains key's.\n *\n * Read in the bracket form, so Next never inlines a value into a build.\n */\n\n/** The env vars, named once for the refusal that lists what is missing. */\nexport const OUTREACH_ENV = {\n clientId: 'GOOGLE_OUTREACH_CLIENT_ID',\n clientSecret: 'GOOGLE_OUTREACH_CLIENT_SECRET',\n tokenKey: 'OUTREACH_TOKEN_KEY',\n} as const\n\nexport interface OutreachGoogleConfig {\n clientId: string\n clientSecret: string\n keyring: SecretBoxKeyring\n}\n\nexport type OutreachGoogleConfigResult =\n | { configured: true; config: OutreachGoogleConfig }\n | {\n configured: false\n /** The env var names that are unset or unusable — never their values. */\n missing: string[]\n }\n\n/** The sentence every route and the panel use for an unconfigured deployment. */\nexport const OUTREACH_NOT_CONFIGURED_MESSAGE =\n 'Connecting a Google mailbox is not configured on this deployment.'\n\n/**
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/outreach-config.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 parseSecretBoxKeyring,\n type SecretBoxKeyring,\n} from '@aglyn/shared-util-tools/secret-box'\nimport type { OutreachMailboxProvider } from '../model/outreach.types'\nimport { isMicrosoftTenant } from '../transport/microsoft-oauth'\n\n/**\n * THE ONE MODULE THAT READS OUTREACH'S CREDENTIALS FROM THE ENVIRONMENT\n * (AGL-2978).\n *\n * - `GOOGLE_OUTREACH_CLIENT_ID` and `GOOGLE_OUTREACH_CLIENT_SECRET` — the\n * OAuth client a rep's Google account grants mailbox access to.\n * - `MICROSOFT_OUTREACH_CLIENT_ID` and `MICROSOFT_OUTREACH_CLIENT_SECRET` —\n * the Microsoft Entra app registration a rep's Microsoft 365 account\n * grants mailbox access to (AGL-3489), and `MICROSOFT_OUTREACH_TENANT`,\n * which tenants may sign in to it: `common` when unset.\n * - `OUTREACH_TOKEN_KEY` — 32 random bytes, base64, sealing every stored\n * refresh token. A comma-separated list rotates: the first key seals, the\n * rest only open, and a token opened under an older key is sealed again\n * under the first.\n *\n * ## Console-only, structurally\n *\n * The client secret and the token key together are the power to send as\n * every connected rep, so neither may be reachable from the tenant runtime,\n * which serves the public internet. They are read here and nowhere else; this\n * module is imported only by Outreach's server half and its console-only\n * declarations; and those are loaded only by the console's server manifests.\n * `outreach-credential-isolation.spec.ts`, beside this plugin's other specs,\n * holds all three facts, the way `sending-domain-credential-isolation.spec.ts`\n * holds the Resend domains key's.\n *\n * Read in the bracket form, so Next never inlines a value into a build.\n */\n\n/** The env vars, named once for the refusal that lists what is missing. */\nexport const OUTREACH_ENV = {\n clientId: 'GOOGLE_OUTREACH_CLIENT_ID',\n clientSecret: 'GOOGLE_OUTREACH_CLIENT_SECRET',\n tokenKey: 'OUTREACH_TOKEN_KEY',\n microsoftClientId: 'MICROSOFT_OUTREACH_CLIENT_ID',\n microsoftClientSecret: 'MICROSOFT_OUTREACH_CLIENT_SECRET',\n microsoftTenant: 'MICROSOFT_OUTREACH_TENANT',\n} as const\n\n/** The tenant a Microsoft connect signs in through when none is named. */\nexport const OUTREACH_MICROSOFT_DEFAULT_TENANT = 'common'\n\nexport interface OutreachGoogleConfig {\n clientId: string\n clientSecret: string\n keyring: SecretBoxKeyring\n}\n\nexport type OutreachGoogleConfigResult =\n | { configured: true; config: OutreachGoogleConfig }\n | {\n configured: false\n /** The env var names that are unset or unusable — never their values. */\n missing: string[]\n }\n\nexport interface OutreachMicrosoftConfig extends OutreachGoogleConfig {\n /** The tenant segment of the identity platform's endpoints. */\n tenant: string\n}\n\nexport type OutreachMicrosoftConfigResult =\n | { configured: true; config: OutreachMicrosoftConfig }\n | { configured: false; missing: string[] }\n\n/** The sentence every route and the panel use for an unconfigured deployment. */\nexport const OUTREACH_NOT_CONFIGURED_MESSAGE =\n 'Connecting a Google mailbox is not configured on this deployment.'\n\n/** The same sentence for a Microsoft connect (AGL-3489). */\nexport const OUTREACH_MICROSOFT_NOT_CONFIGURED_MESSAGE =\n 'Connecting a Microsoft 365 mailbox is not configured on this deployment.'\n\n/** The not-configured sentence for a provider. */\nexport function outreachNotConfiguredMessage(provider: OutreachMailboxProvider): string {\n return provider === 'microsoft' ? OUTREACH_MICROSOFT_NOT_CONFIGURED_MESSAGE : OUTREACH_NOT_CONFIGURED_MESSAGE\n}\n\n/** The shared token key, or `null` when it is unset or unusable. */\nfunction readTokenKeyring(): SecretBoxKeyring | null {\n const tokenKey = String(process.env['OUTREACH_TOKEN_KEY'] ?? '').trim()\n if (!tokenKey) return null\n try {\n return parseSecretBoxKeyring(tokenKey)\n } catch {\n return null\n }\n}\n\n/** Reads the three variables. Never throws; an unusable key counts as missing. */\nexport function readOutreachGoogleConfig(): OutreachGoogleConfigResult {\n const clientId = String(process.env['GOOGLE_OUTREACH_CLIENT_ID'] ?? '').trim()\n const clientSecret = String(process.env['GOOGLE_OUTREACH_CLIENT_SECRET'] ?? '').trim()\n\n const missing: string[] = []\n if (!clientId) missing.push(OUTREACH_ENV.clientId)\n if (!clientSecret) missing.push(OUTREACH_ENV.clientSecret)\n const keyring = readTokenKeyring()\n if (!keyring) missing.push(OUTREACH_ENV.tokenKey)\n\n if (missing.length || !keyring) return { configured: false, missing }\n return { configured: true, config: { clientId, clientSecret, keyring } }\n}\n\n/**\n * Reads the Microsoft variables and the shared token key. Never throws; a\n * tenant that is not one the identity platform addresses counts as missing.\n */\nexport function readOutreachMicrosoftConfig(): OutreachMicrosoftConfigResult {\n const clientId = String(process.env['MICROSOFT_OUTREACH_CLIENT_ID'] ?? '').trim()\n const clientSecret = String(process.env['MICROSOFT_OUTREACH_CLIENT_SECRET'] ?? '').trim()\n const tenant = String(process.env['MICROSOFT_OUTREACH_TENANT'] ?? '').trim() || OUTREACH_MICROSOFT_DEFAULT_TENANT\n\n const missing: string[] = []\n if (!clientId) missing.push(OUTREACH_ENV.microsoftClientId)\n if (!clientSecret) missing.push(OUTREACH_ENV.microsoftClientSecret)\n if (!isMicrosoftTenant(tenant)) missing.push(OUTREACH_ENV.microsoftTenant)\n const keyring = readTokenKeyring()\n if (!keyring) missing.push(OUTREACH_ENV.tokenKey)\n\n if (missing.length || !keyring) return { configured: false, missing }\n return { configured: true, config: { clientId, clientSecret, tenant, keyring } }\n}\n"],"names":["parseSecretBoxKeyring","isMicrosoftTenant","OUTREACH_ENV","clientId","clientSecret","tokenKey","microsoftClientId","microsoftClientSecret","microsoftTenant","OUTREACH_MICROSOFT_DEFAULT_TENANT","OUTREACH_NOT_CONFIGURED_MESSAGE","OUTREACH_MICROSOFT_NOT_CONFIGURED_MESSAGE","outreachNotConfiguredMessage","provider","readTokenKeyring","process","String","env","trim","readOutreachGoogleConfig","missing","push","keyring","length","configured","config","readOutreachMicrosoftConfig","tenant"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,qBAAqB,QAEhB,sCAAqC;AAE5C,SAASC,iBAAiB,QAAQ,kCAA8B;AAEhE;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2BC,GAED,yEAAyE,GACzE,OAAO,MAAMC,eAAe;IAC1BC,UAAU;IACVC,cAAc;IACdC,UAAU;IACVC,mBAAmB;IACnBC,uBAAuB;IACvBC,iBAAiB;AACnB,EAAU;AAEV,wEAAwE,GACxE,OAAO,MAAMC,oCAAoC,SAAQ;AAyBzD,+EAA+E,GAC/E,OAAO,MAAMC,kCACX,oEAAmE;AAErE,0DAA0D,GAC1D,OAAO,MAAMC,4CACX,2EAA0E;AAE5E,gDAAgD,GAChD,OAAO,SAASC,6BAA6BC,QAAiC;IAC5E,OAAOA,aAAa,cAAcF,4CAA4CD;AAChF;AAEA,kEAAkE,GAClE,SAASI;QACiBC;IAAxB,MAAMV,WAAWW,QAAOD,kCAAAA,QAAQE,GAAG,CAAC,qBAAqB,YAAjCF,kCAAqC,IAAIG,IAAI;IACrE,IAAI,CAACb,UAAU,OAAO;IACtB,IAAI;QACF,OAAOL,sBAAsBK;IAC/B,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA,gFAAgF,GAChF,OAAO,SAASc;QACUJ,wCACIA;IAD5B,MAAMZ,WAAWa,QAAOD,yCAAAA,QAAQE,GAAG,CAAC,4BAA4B,YAAxCF,yCAA4C,IAAIG,IAAI;IAC5E,MAAMd,eAAeY,QAAOD,6CAAAA,QAAQE,GAAG,CAAC,gCAAgC,YAA5CF,6CAAgD,IAAIG,IAAI;IAEpF,MAAME,UAAoB,EAAE;IAC5B,IAAI,CAACjB,UAAUiB,QAAQC,IAAI,CAACnB,aAAaC,QAAQ;IACjD,IAAI,CAACC,cAAcgB,QAAQC,IAAI,CAACnB,aAAaE,YAAY;IACzD,MAAMkB,UAAUR;IAChB,IAAI,CAACQ,SAASF,QAAQC,IAAI,CAACnB,aAAaG,QAAQ;IAEhD,IAAIe,QAAQG,MAAM,IAAI,CAACD,SAAS,OAAO;QAAEE,YAAY;QAAOJ;IAAQ;IACpE,OAAO;QAAEI,YAAY;QAAMC,QAAQ;YAAEtB;YAAUC;YAAckB;QAAQ;IAAE;AACzE;AAEA;;;CAGC,GACD,OAAO,SAASI;QACUX,2CACIA,+CACNA;IAFtB,MAAMZ,WAAWa,QAAOD,4CAAAA,QAAQE,GAAG,CAAC,+BAA+B,YAA3CF,4CAA+C,IAAIG,IAAI;IAC/E,MAAMd,eAAeY,QAAOD,gDAAAA,QAAQE,GAAG,CAAC,mCAAmC,YAA/CF,gDAAmD,IAAIG,IAAI;IACvF,MAAMS,SAASX,QAAOD,yCAAAA,QAAQE,GAAG,CAAC,4BAA4B,YAAxCF,yCAA4C,IAAIG,IAAI,MAAMT;IAEhF,MAAMW,UAAoB,EAAE;IAC5B,IAAI,CAACjB,UAAUiB,QAAQC,IAAI,CAACnB,aAAaI,iBAAiB;IAC1D,IAAI,CAACF,cAAcgB,QAAQC,IAAI,CAACnB,aAAaK,qBAAqB;IAClE,IAAI,CAACN,kBAAkB0B,SAASP,QAAQC,IAAI,CAACnB,aAAaM,eAAe;IACzE,MAAMc,UAAUR;IAChB,IAAI,CAACQ,SAASF,QAAQC,IAAI,CAACnB,aAAaG,QAAQ;IAEhD,IAAIe,QAAQG,MAAM,IAAI,CAACD,SAAS,OAAO;QAAEE,YAAY;QAAOJ;IAAQ;IACpE,OAAO;QAAEI,YAAY;QAAMC,QAAQ;YAAEtB;YAAUC;YAAcuB;YAAQL;QAAQ;IAAE;AACjF"}
|
|
@@ -21,7 +21,7 @@ import { readOrgId } from "./mailbox-gate.js";
|
|
|
21
21
|
import { createOutreachMailboxRoutes } from "./mailbox-routes.js";
|
|
22
22
|
import { readOutreachOAuthState } from "./oauth-state.js";
|
|
23
23
|
import { outreachOAuthRedirectUri } from "./oauth-redirect.js";
|
|
24
|
-
import { readOutreachGoogleConfig } from "./outreach-config.js";
|
|
24
|
+
import { readOutreachGoogleConfig, readOutreachMicrosoftConfig } from "./outreach-config.js";
|
|
25
25
|
/**
|
|
26
26
|
* Wires the mailbox routes into the console dispatcher (AGL-2978), with the
|
|
27
27
|
* production dependencies and each route's release subject.
|
|
@@ -47,6 +47,7 @@ import { readOutreachGoogleConfig } from "./outreach-config.js";
|
|
|
47
47
|
lockdownRefusal: async (options)=>(await import("@aglyn/tenant-data-admin/server/lockdown")).lockdownRefusal(options)
|
|
48
48
|
},
|
|
49
49
|
readConfig: readOutreachGoogleConfig,
|
|
50
|
+
readMicrosoftConfig: readOutreachMicrosoftConfig,
|
|
50
51
|
stateSigningConfigured: ()=>{
|
|
51
52
|
try {
|
|
52
53
|
return Boolean(tokenSigningSecret());
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/register-mailbox-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 {\n registerPluginApiRoute,\n type PluginApiRequestSubject,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { tokenSigningSecret } from '@aglyn/tenant-data-admin/server/media-signing'\nimport { OUTREACH_API_ROUTES } from '../constants/api-routes'\nimport { readOrgId } from './mailbox-gate'\nimport {\n createOutreachMailboxRoutes,\n type OutreachMailboxRouteDeps,\n} from './mailbox-routes'\nimport { readOutreachOAuthState } from './oauth-state'\nimport { outreachOAuthRedirectUri } from './oauth-redirect'\nimport { readOutreachGoogleConfig } from './outreach-config'\n\n/**\n * Wires the mailbox routes into the console dispatcher (AGL-2978), with the\n * production dependencies and each route's release subject.\n */\n\n/**\n * The platform's own dependencies. Specs build their own.\n *\n * The permission resolver, the lockdown verdict, the rate limiter, the\n * activity log and the alias store are imported when a request first needs\n * them rather than when the bundle registers: `org-permissions` reaches the\n * whole tenant data barrel, and the manifest loads this bundle into every\n * console API process whether or not a mailbox route is ever called.\n */\nexport function defaultOutreachMailboxRouteDeps(): OutreachMailboxRouteDeps {\n return {\n firestore: () => firebaseAdmin.app().firestore(),\n gate: {\n verifyIdToken: (idToken) => firebaseAdmin.app().auth().verifyIdToken(idToken),\n resolveOrgPermissions: async (uid, context) =>\n (await import('@aglyn/tenant-runtime/org-permissions')).resolveOrgPermissions(uid, context),\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) =>\n (await import('@aglyn/tenant-data-admin/server/lockdown')).lockdownRefusal(options),\n },\n readConfig: readOutreachGoogleConfig,\n stateSigningConfigured: () => {\n try {\n return Boolean(tokenSigningSecret())\n } catch {\n return false\n }\n },\n redirectUri: outreachOAuthRedirectUri,\n now: Date.now,\n transport: {},\n consumeRateLimit: async (key, options) =>\n (await import('@aglyn/tenant-data-admin/server/rate-limit-store')).consumeRateLimit(key, options),\n logOrgActivity: async (orgId, actor, action, target) =>\n (await import('@aglyn/tenant-data-admin/server/organizations')).logOrgActivity(orgId, actor, action, target),\n confirmAliasesByProvider: async (firestore, input) =>\n (await import('@aglyn/tenant-data-admin/server/member-email-aliases')).confirmMemberEmailAliasesByProvider(\n firestore,\n input,\n ),\n readSenderReadiness: async (expectation, options) =>\n (await import('@aglyn/tenant-data-admin/server/sender-readiness')).readSenderReadiness(expectation, options),\n }\n}\n\n/**\n * The org an authenticated mailbox request names — query or JSON body — for\n * the release gate. Unverified, exactly as a `hostId` is: the gate only\n * chooses which organization's rollout to read, and the route's own gate\n * refuses anyone who is not a member of it.\n */\nexport async function outreachOrgSubject(request: Request): Promise<PluginApiRequestSubject | null> {\n const fromQuery = readOrgId(new URL(request.url).searchParams.get('orgId'))\n if (fromQuery) return { orgId: fromQuery }\n if (request.method === 'GET' || request.method === 'HEAD') return null\n const body = (await request.json().catch(() => null)) as { orgId?: unknown } | null\n const orgId = readOrgId(body?.orgId)\n return orgId ? { orgId } : null\n}\n\n/**\n * The org AND member Google's redirect is for, read off the signed state.\n * Only a state whose signature verifies names anyone; an expired one still\n * does, so the rep who took too long lands on a page that says so rather than\n * on a 404.\n */\nexport function outreachOAuthCallbackSubject(request: Request): PluginApiRequestSubject | null {\n const read = readOutreachOAuthState(new URL(request.url).searchParams.get('state'), Date.now())\n if (read.ok) return { orgId: read.claims.orgId, uid: read.claims.uid }\n if (read.ok === false && read.refusal === 'state-expired') {\n const { claims } = read as { claims: { orgId: string; uid: string } }\n return { orgId: claims.orgId, uid: claims.uid }\n }\n return null\n}\n\nexport function registerOutreachMailboxRoutes(\n deps: OutreachMailboxRouteDeps = defaultOutreachMailboxRouteDeps(),\n): void {\n const routes = createOutreachMailboxRoutes(deps)\n const orgSubject = { subject: outreachOrgSubject }\n registerPluginApiRoute(OUTREACH_API_ROUTES.mailboxesAvailability, { web: routes.availability }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.mailboxesConnect, { web: routes.connect }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.mailboxesOAuthCallback,\n { web: routes.oauthCallback },\n { subject: outreachOAuthCallbackSubject },\n )\n registerPluginApiRoute(OUTREACH_API_ROUTES.mailboxesConnectComplete, { web: routes.connectComplete }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.mailboxesSettings, { web: routes.settings }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.mailboxesStatus, { web: routes.status }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.mailboxesTest, { web: routes.test }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.mailboxesDisconnect, { web: routes.disconnect }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.mailboxesReadiness, { web: routes.readiness }, orgSubject)\n}\n"],"names":["registerPluginApiRoute","firebaseAdmin","tokenSigningSecret","OUTREACH_API_ROUTES","readOrgId","createOutreachMailboxRoutes","readOutreachOAuthState","outreachOAuthRedirectUri","readOutreachGoogleConfig","defaultOutreachMailboxRouteDeps","firestore","app","gate","verifyIdToken","idToken","auth","resolveOrgPermissions","uid","context","readOrg","orgId","snapshot","collection","doc","get","exists","data","lockdownRefusal","options","readConfig","stateSigningConfigured","Boolean","redirectUri","now","Date","transport","consumeRateLimit","key","logOrgActivity","actor","action","target","confirmAliasesByProvider","input","confirmMemberEmailAliasesByProvider","readSenderReadiness","expectation","outreachOrgSubject","request","fromQuery","URL","url","searchParams","method","body","json","catch","outreachOAuthCallbackSubject","read","ok","claims","refusal","registerOutreachMailboxRoutes","deps","routes","orgSubject","subject","mailboxesAvailability","web","availability","mailboxesConnect","connect","mailboxesOAuthCallback","oauthCallback","mailboxesConnectComplete","connectComplete","mailboxesSettings","settings","mailboxesStatus","status","mailboxesTest","test","mailboxesDisconnect","disconnect","mailboxesReadiness","readiness"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/register-mailbox-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 {\n registerPluginApiRoute,\n type PluginApiRequestSubject,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { tokenSigningSecret } from '@aglyn/tenant-data-admin/server/media-signing'\nimport { OUTREACH_API_ROUTES } from '../constants/api-routes'\nimport { readOrgId } from './mailbox-gate'\nimport {\n createOutreachMailboxRoutes,\n type OutreachMailboxRouteDeps,\n} from './mailbox-routes'\nimport { readOutreachOAuthState } from './oauth-state'\nimport { outreachOAuthRedirectUri } from './oauth-redirect'\nimport { readOutreachGoogleConfig, readOutreachMicrosoftConfig } from './outreach-config'\n\n/**\n * Wires the mailbox routes into the console dispatcher (AGL-2978), with the\n * production dependencies and each route's release subject.\n */\n\n/**\n * The platform's own dependencies. Specs build their own.\n *\n * The permission resolver, the lockdown verdict, the rate limiter, the\n * activity log and the alias store are imported when a request first needs\n * them rather than when the bundle registers: `org-permissions` reaches the\n * whole tenant data barrel, and the manifest loads this bundle into every\n * console API process whether or not a mailbox route is ever called.\n */\nexport function defaultOutreachMailboxRouteDeps(): OutreachMailboxRouteDeps {\n return {\n firestore: () => firebaseAdmin.app().firestore(),\n gate: {\n verifyIdToken: (idToken) => firebaseAdmin.app().auth().verifyIdToken(idToken),\n resolveOrgPermissions: async (uid, context) =>\n (await import('@aglyn/tenant-runtime/org-permissions')).resolveOrgPermissions(uid, context),\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) =>\n (await import('@aglyn/tenant-data-admin/server/lockdown')).lockdownRefusal(options),\n },\n readConfig: readOutreachGoogleConfig,\n readMicrosoftConfig: readOutreachMicrosoftConfig,\n stateSigningConfigured: () => {\n try {\n return Boolean(tokenSigningSecret())\n } catch {\n return false\n }\n },\n redirectUri: outreachOAuthRedirectUri,\n now: Date.now,\n transport: {},\n consumeRateLimit: async (key, options) =>\n (await import('@aglyn/tenant-data-admin/server/rate-limit-store')).consumeRateLimit(key, options),\n logOrgActivity: async (orgId, actor, action, target) =>\n (await import('@aglyn/tenant-data-admin/server/organizations')).logOrgActivity(orgId, actor, action, target),\n confirmAliasesByProvider: async (firestore, input) =>\n (await import('@aglyn/tenant-data-admin/server/member-email-aliases')).confirmMemberEmailAliasesByProvider(\n firestore,\n input,\n ),\n readSenderReadiness: async (expectation, options) =>\n (await import('@aglyn/tenant-data-admin/server/sender-readiness')).readSenderReadiness(expectation, options),\n }\n}\n\n/**\n * The org an authenticated mailbox request names — query or JSON body — for\n * the release gate. Unverified, exactly as a `hostId` is: the gate only\n * chooses which organization's rollout to read, and the route's own gate\n * refuses anyone who is not a member of it.\n */\nexport async function outreachOrgSubject(request: Request): Promise<PluginApiRequestSubject | null> {\n const fromQuery = readOrgId(new URL(request.url).searchParams.get('orgId'))\n if (fromQuery) return { orgId: fromQuery }\n if (request.method === 'GET' || request.method === 'HEAD') return null\n const body = (await request.json().catch(() => null)) as { orgId?: unknown } | null\n const orgId = readOrgId(body?.orgId)\n return orgId ? { orgId } : null\n}\n\n/**\n * The org AND member Google's redirect is for, read off the signed state.\n * Only a state whose signature verifies names anyone; an expired one still\n * does, so the rep who took too long lands on a page that says so rather than\n * on a 404.\n */\nexport function outreachOAuthCallbackSubject(request: Request): PluginApiRequestSubject | null {\n const read = readOutreachOAuthState(new URL(request.url).searchParams.get('state'), Date.now())\n if (read.ok) return { orgId: read.claims.orgId, uid: read.claims.uid }\n if (read.ok === false && read.refusal === 'state-expired') {\n const { claims } = read as { claims: { orgId: string; uid: string } }\n return { orgId: claims.orgId, uid: claims.uid }\n }\n return null\n}\n\nexport function registerOutreachMailboxRoutes(\n deps: OutreachMailboxRouteDeps = defaultOutreachMailboxRouteDeps(),\n): void {\n const routes = createOutreachMailboxRoutes(deps)\n const orgSubject = { subject: outreachOrgSubject }\n registerPluginApiRoute(OUTREACH_API_ROUTES.mailboxesAvailability, { web: routes.availability }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.mailboxesConnect, { web: routes.connect }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.mailboxesOAuthCallback,\n { web: routes.oauthCallback },\n { subject: outreachOAuthCallbackSubject },\n )\n registerPluginApiRoute(OUTREACH_API_ROUTES.mailboxesConnectComplete, { web: routes.connectComplete }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.mailboxesSettings, { web: routes.settings }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.mailboxesStatus, { web: routes.status }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.mailboxesTest, { web: routes.test }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.mailboxesDisconnect, { web: routes.disconnect }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.mailboxesReadiness, { web: routes.readiness }, orgSubject)\n}\n"],"names":["registerPluginApiRoute","firebaseAdmin","tokenSigningSecret","OUTREACH_API_ROUTES","readOrgId","createOutreachMailboxRoutes","readOutreachOAuthState","outreachOAuthRedirectUri","readOutreachGoogleConfig","readOutreachMicrosoftConfig","defaultOutreachMailboxRouteDeps","firestore","app","gate","verifyIdToken","idToken","auth","resolveOrgPermissions","uid","context","readOrg","orgId","snapshot","collection","doc","get","exists","data","lockdownRefusal","options","readConfig","readMicrosoftConfig","stateSigningConfigured","Boolean","redirectUri","now","Date","transport","consumeRateLimit","key","logOrgActivity","actor","action","target","confirmAliasesByProvider","input","confirmMemberEmailAliasesByProvider","readSenderReadiness","expectation","outreachOrgSubject","request","fromQuery","URL","url","searchParams","method","body","json","catch","outreachOAuthCallbackSubject","read","ok","claims","refusal","registerOutreachMailboxRoutes","deps","routes","orgSubject","subject","mailboxesAvailability","web","availability","mailboxesConnect","connect","mailboxesOAuthCallback","oauthCallback","mailboxesConnectComplete","connectComplete","mailboxesSettings","settings","mailboxesStatus","status","mailboxesTest","test","mailboxesDisconnect","disconnect","mailboxesReadiness","readiness"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,sBAAsB,QAEjB,sBAAqB;AAC5B,SAASC,aAAa,QAAQ,iDAAgD;AAC9E,SAASC,kBAAkB,QAAQ,gDAA+C;AAClF,SAASC,mBAAmB,QAAQ,6BAAyB;AAC7D,SAASC,SAAS,QAAQ,oBAAgB;AAC1C,SACEC,2BAA2B,QAEtB,sBAAkB;AACzB,SAASC,sBAAsB,QAAQ,mBAAe;AACtD,SAASC,wBAAwB,QAAQ,sBAAkB;AAC3D,SAASC,wBAAwB,EAAEC,2BAA2B,QAAQ,uBAAmB;AAEzF;;;CAGC,GAED;;;;;;;;CAQC,GACD,OAAO,SAASC;IACd,OAAO;QACLC,WAAW,IAAMV,cAAcW,GAAG,GAAGD,SAAS;QAC9CE,MAAM;YACJC,eAAe,CAACC,UAAYd,cAAcW,GAAG,GAAGI,IAAI,GAAGF,aAAa,CAACC;YACrEE,uBAAuB,OAAOC,KAAKC,UACjC,AAAC,CAAA,MAAM,MAAM,CAAC,wCAAuC,EAAGF,qBAAqB,CAACC,KAAKC;YACrFC,SAAS,OAAOC;oBAEYC;gBAD1B,MAAMA,WAAW,MAAMrB,cAAcW,GAAG,GAAGD,SAAS,GAAGY,UAAU,CAAC,QAAQC,GAAG,CAACH,OAAOI,GAAG;gBACxF,OAAOH,SAASI,MAAM,IAAIJ,iBAAAA,SAASK,IAAI,cAAbL,iBAAmB,CAAC,IAAK;YACrD;YACAM,iBAAiB,OAAOC,UACtB,AAAC,CAAA,MAAM,MAAM,CAAC,2CAA0C,EAAGD,eAAe,CAACC;QAC/E;QACAC,YAAYtB;QACZuB,qBAAqBtB;QACrBuB,wBAAwB;YACtB,IAAI;gBACF,OAAOC,QAAQ/B;YACjB,EAAE,eAAM;gBACN,OAAO;YACT;QACF;QACAgC,aAAa3B;QACb4B,KAAKC,KAAKD,GAAG;QACbE,WAAW,CAAC;QACZC,kBAAkB,OAAOC,KAAKV,UAC5B,AAAC,CAAA,MAAM,MAAM,CAAC,mDAAkD,EAAGS,gBAAgB,CAACC,KAAKV;QAC3FW,gBAAgB,OAAOnB,OAAOoB,OAAOC,QAAQC,SAC3C,AAAC,CAAA,MAAM,MAAM,CAAC,gDAA+C,EAAGH,cAAc,CAACnB,OAAOoB,OAAOC,QAAQC;QACvGC,0BAA0B,OAAOjC,WAAWkC,QAC1C,AAAC,CAAA,MAAM,MAAM,CAAC,uDAAsD,EAAGC,mCAAmC,CACxGnC,WACAkC;QAEJE,qBAAqB,OAAOC,aAAanB,UACvC,AAAC,CAAA,MAAM,MAAM,CAAC,mDAAkD,EAAGkB,mBAAmB,CAACC,aAAanB;IACxG;AACF;AAEA;;;;;CAKC,GACD,OAAO,eAAeoB,mBAAmBC,OAAgB;IACvD,MAAMC,YAAY/C,UAAU,IAAIgD,IAAIF,QAAQG,GAAG,EAAEC,YAAY,CAAC7B,GAAG,CAAC;IAClE,IAAI0B,WAAW,OAAO;QAAE9B,OAAO8B;IAAU;IACzC,IAAID,QAAQK,MAAM,KAAK,SAASL,QAAQK,MAAM,KAAK,QAAQ,OAAO;IAClE,MAAMC,OAAQ,MAAMN,QAAQO,IAAI,GAAGC,KAAK,CAAC,IAAM;IAC/C,MAAMrC,QAAQjB,UAAUoD,wBAAAA,KAAMnC,KAAK;IACnC,OAAOA,QAAQ;QAAEA;IAAM,IAAI;AAC7B;AAEA;;;;;CAKC,GACD,OAAO,SAASsC,6BAA6BT,OAAgB;IAC3D,MAAMU,OAAOtD,uBAAuB,IAAI8C,IAAIF,QAAQG,GAAG,EAAEC,YAAY,CAAC7B,GAAG,CAAC,UAAUW,KAAKD,GAAG;IAC5F,IAAIyB,KAAKC,EAAE,EAAE,OAAO;QAAExC,OAAOuC,KAAKE,MAAM,CAACzC,KAAK;QAAEH,KAAK0C,KAAKE,MAAM,CAAC5C,GAAG;IAAC;IACrE,IAAI0C,KAAKC,EAAE,KAAK,SAASD,KAAKG,OAAO,KAAK,iBAAiB;QACzD,MAAM,EAAED,MAAM,EAAE,GAAGF;QACnB,OAAO;YAAEvC,OAAOyC,OAAOzC,KAAK;YAAEH,KAAK4C,OAAO5C,GAAG;QAAC;IAChD;IACA,OAAO;AACT;AAEA,OAAO,SAAS8C,8BACdC,OAAiCvD,iCAAiC;IAElE,MAAMwD,SAAS7D,4BAA4B4D;IAC3C,MAAME,aAAa;QAAEC,SAASnB;IAAmB;IACjDjD,uBAAuBG,oBAAoBkE,qBAAqB,EAAE;QAAEC,KAAKJ,OAAOK,YAAY;IAAC,GAAGJ;IAChGnE,uBAAuBG,oBAAoBqE,gBAAgB,EAAE;QAAEF,KAAKJ,OAAOO,OAAO;IAAC,GAAGN;IACtFnE,uBACEG,oBAAoBuE,sBAAsB,EAC1C;QAAEJ,KAAKJ,OAAOS,aAAa;IAAC,GAC5B;QAAEP,SAAST;IAA6B;IAE1C3D,uBAAuBG,oBAAoByE,wBAAwB,EAAE;QAAEN,KAAKJ,OAAOW,eAAe;IAAC,GAAGV;IACtGnE,uBAAuBG,oBAAoB2E,iBAAiB,EAAE;QAAER,KAAKJ,OAAOa,QAAQ;IAAC,GAAGZ;IACxFnE,uBAAuBG,oBAAoB6E,eAAe,EAAE;QAAEV,KAAKJ,OAAOe,MAAM;IAAC,GAAGd;IACpFnE,uBAAuBG,oBAAoB+E,aAAa,EAAE;QAAEZ,KAAKJ,OAAOiB,IAAI;IAAC,GAAGhB;IAChFnE,uBAAuBG,oBAAoBiF,mBAAmB,EAAE;QAAEd,KAAKJ,OAAOmB,UAAU;IAAC,GAAGlB;IAC5FnE,uBAAuBG,oBAAoBmF,kBAAkB,EAAE;QAAEhB,KAAKJ,OAAOqB,SAAS;IAAC,GAAGpB;AAC5F"}
|
|
@@ -115,8 +115,12 @@ export interface OutreachTimestamps {
|
|
|
115
115
|
createdAtMs: number;
|
|
116
116
|
updatedAtMs: number;
|
|
117
117
|
}
|
|
118
|
-
/**
|
|
119
|
-
|
|
118
|
+
/**
|
|
119
|
+
* The mail providers a mailbox can be connected through: a Google Workspace
|
|
120
|
+
* account (Gmail), or a Microsoft 365 account through Microsoft Graph
|
|
121
|
+
* (AGL-3489).
|
|
122
|
+
*/
|
|
123
|
+
export declare const OUTREACH_MAILBOX_PROVIDERS: readonly ["google", "microsoft"];
|
|
120
124
|
export type OutreachMailboxProvider = (typeof OUTREACH_MAILBOX_PROVIDERS)[number];
|
|
121
125
|
/**
|
|
122
126
|
* Where a mailbox stands.
|
|
@@ -604,6 +608,13 @@ export interface OutreachSequence extends OutreachTimestamps {
|
|
|
604
608
|
* mailbox's own screens select sequences by it.
|
|
605
609
|
*/
|
|
606
610
|
mailboxId: string;
|
|
611
|
+
/**
|
|
612
|
+
* More mailboxes the sequence sends from in rotation (AGL-3489), after
|
|
613
|
+
* `mailboxId`: each person enrolled is given one, and all of their steps
|
|
614
|
+
* go from it (`engine/mailbox-rotation`). Absent when it sends from its
|
|
615
|
+
* own alone.
|
|
616
|
+
*/
|
|
617
|
+
mailboxIds?: string[];
|
|
607
618
|
steps: OutreachSequenceStep[];
|
|
608
619
|
settings: OutreachSequenceSettings;
|
|
609
620
|
status: OutreachSequenceStatus;
|
|
@@ -92,8 +92,13 @@
|
|
|
92
92
|
/** `orgs/{orgId}/<collection>` for one of the org-scoped collections. */ export function outreachOrgCollectionPath(orgId, collection) {
|
|
93
93
|
return `orgs/${orgId}/${OUTREACH_COLLECTIONS[collection]}`;
|
|
94
94
|
}
|
|
95
|
-
/**
|
|
96
|
-
|
|
95
|
+
/**
|
|
96
|
+
* The mail providers a mailbox can be connected through: a Google Workspace
|
|
97
|
+
* account (Gmail), or a Microsoft 365 account through Microsoft Graph
|
|
98
|
+
* (AGL-3489).
|
|
99
|
+
*/ export const OUTREACH_MAILBOX_PROVIDERS = [
|
|
100
|
+
'google',
|
|
101
|
+
'microsoft'
|
|
97
102
|
];
|
|
98
103
|
/**
|
|
99
104
|
* Where a mailbox stands.
|