@aglyn/plugins-outreach 1.0.0-beta.143
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/LICENSE +201 -0
- package/README.md +93 -0
- package/package.json +52 -0
- package/src/index.d.ts +22 -0
- package/src/index.js +29 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/compliance-section.d.ts +20 -0
- package/src/lib/components/compliance-section.js +291 -0
- package/src/lib/components/compliance-section.js.map +1 -0
- package/src/lib/components/enroll-dialog.d.ts +36 -0
- package/src/lib/components/enroll-dialog.js +656 -0
- package/src/lib/components/enroll-dialog.js.map +1 -0
- package/src/lib/components/enrollments-table.d.ts +28 -0
- package/src/lib/components/enrollments-table.js +375 -0
- package/src/lib/components/enrollments-table.js.map +1 -0
- package/src/lib/components/mailbox-card.d.ts +46 -0
- package/src/lib/components/mailbox-card.js +517 -0
- package/src/lib/components/mailbox-card.js.map +1 -0
- package/src/lib/components/mailboxes-section.d.ts +24 -0
- package/src/lib/components/mailboxes-section.js +258 -0
- package/src/lib/components/mailboxes-section.js.map +1 -0
- package/src/lib/components/outreach-console-page.d.ts +17 -0
- package/src/lib/components/outreach-console-page.js +81 -0
- package/src/lib/components/outreach-console-page.js.map +1 -0
- package/src/lib/components/outreach-console-sections.d.ts +43 -0
- package/src/lib/components/outreach-console-sections.js +55 -0
- package/src/lib/components/outreach-console-sections.js.map +1 -0
- package/src/lib/components/outreach-ui.d.ts +51 -0
- package/src/lib/components/outreach-ui.js +171 -0
- package/src/lib/components/outreach-ui.js.map +1 -0
- package/src/lib/components/send-window-fields.d.ts +25 -0
- package/src/lib/components/send-window-fields.js +178 -0
- package/src/lib/components/send-window-fields.js.map +1 -0
- package/src/lib/components/sequence-detail.d.ts +54 -0
- package/src/lib/components/sequence-detail.js +435 -0
- package/src/lib/components/sequence-detail.js.map +1 -0
- package/src/lib/components/sequence-editor.d.ts +37 -0
- package/src/lib/components/sequence-editor.js +485 -0
- package/src/lib/components/sequence-editor.js.map +1 -0
- package/src/lib/components/sequence-mailbox-picker.d.ts +44 -0
- package/src/lib/components/sequence-mailbox-picker.js +137 -0
- package/src/lib/components/sequence-mailbox-picker.js.map +1 -0
- package/src/lib/components/sequence-preview.d.ts +27 -0
- package/src/lib/components/sequence-preview.js +146 -0
- package/src/lib/components/sequence-preview.js.map +1 -0
- package/src/lib/components/sequence-step-card.d.ts +31 -0
- package/src/lib/components/sequence-step-card.js +326 -0
- package/src/lib/components/sequence-step-card.js.map +1 -0
- package/src/lib/components/sequences-section.d.ts +40 -0
- package/src/lib/components/sequences-section.js +310 -0
- package/src/lib/components/sequences-section.js.map +1 -0
- package/src/lib/components/use-outreach-api.d.ts +38 -0
- package/src/lib/components/use-outreach-api.js +139 -0
- package/src/lib/components/use-outreach-api.js.map +1 -0
- package/src/lib/components/use-outreach-crm.d.ts +50 -0
- package/src/lib/components/use-outreach-crm.js +200 -0
- package/src/lib/components/use-outreach-crm.js.map +1 -0
- package/src/lib/components/use-outreach-data.d.ts +55 -0
- package/src/lib/components/use-outreach-data.js +199 -0
- package/src/lib/components/use-outreach-data.js.map +1 -0
- package/src/lib/components/use-outreach-mailbox-api.d.ts +30 -0
- package/src/lib/components/use-outreach-mailbox-api.js +101 -0
- package/src/lib/components/use-outreach-mailbox-api.js.map +1 -0
- package/src/lib/components/use-outreach-mailboxes.d.ts +22 -0
- package/src/lib/components/use-outreach-mailboxes.js +68 -0
- package/src/lib/components/use-outreach-mailboxes.js.map +1 -0
- package/src/lib/components/use-outreach-settings.d.ts +17 -0
- package/src/lib/components/use-outreach-settings.js +64 -0
- package/src/lib/components/use-outreach-settings.js.map +1 -0
- package/src/lib/constants/api-routes.d.ts +68 -0
- package/src/lib/constants/api-routes.js +57 -0
- package/src/lib/constants/api-routes.js.map +1 -0
- package/src/lib/constants/bundle-common.d.ts +46 -0
- package/src/lib/constants/bundle-common.js +55 -0
- package/src/lib/constants/bundle-common.js.map +1 -0
- package/src/lib/constants/runtime-jobs.d.ts +24 -0
- package/src/lib/constants/runtime-jobs.js +24 -0
- package/src/lib/constants/runtime-jobs.js.map +1 -0
- package/src/lib/declarations.console-server.d.ts +38 -0
- package/src/lib/declarations.console-server.js +99 -0
- package/src/lib/declarations.console-server.js.map +1 -0
- package/src/lib/engine/compose.d.ts +86 -0
- package/src/lib/engine/compose.js +212 -0
- package/src/lib/engine/compose.js.map +1 -0
- package/src/lib/engine/delivery-status.d.ts +53 -0
- package/src/lib/engine/delivery-status.js +184 -0
- package/src/lib/engine/delivery-status.js.map +1 -0
- package/src/lib/engine/do-not-contact.d.ts +18 -0
- package/src/lib/engine/do-not-contact.js +34 -0
- package/src/lib/engine/do-not-contact.js.map +1 -0
- package/src/lib/engine/enrollment-state.d.ts +156 -0
- package/src/lib/engine/enrollment-state.js +303 -0
- package/src/lib/engine/enrollment-state.js.map +1 -0
- package/src/lib/engine/gates.d.ts +125 -0
- package/src/lib/engine/gates.js +232 -0
- package/src/lib/engine/gates.js.map +1 -0
- package/src/lib/engine/index.d.ts +28 -0
- package/src/lib/engine/index.js +33 -0
- package/src/lib/engine/index.js.map +1 -0
- package/src/lib/engine/mailbox-health.d.ts +49 -0
- package/src/lib/engine/mailbox-health.js +83 -0
- package/src/lib/engine/mailbox-health.js.map +1 -0
- package/src/lib/engine/opt-out-intent.d.ts +41 -0
- package/src/lib/engine/opt-out-intent.js +220 -0
- package/src/lib/engine/opt-out-intent.js.map +1 -0
- package/src/lib/engine/recipient-country.d.ts +41 -0
- package/src/lib/engine/recipient-country.js +171 -0
- package/src/lib/engine/recipient-country.js.map +1 -0
- package/src/lib/engine/schedule.d.ts +70 -0
- package/src/lib/engine/schedule.js +115 -0
- package/src/lib/engine/schedule.js.map +1 -0
- package/src/lib/engine/sending-capacity.d.ts +96 -0
- package/src/lib/engine/sending-capacity.js +172 -0
- package/src/lib/engine/sending-capacity.js.map +1 -0
- package/src/lib/engine/sequence-validation.d.ts +90 -0
- package/src/lib/engine/sequence-validation.js +291 -0
- package/src/lib/engine/sequence-validation.js.map +1 -0
- package/src/lib/engine/thread-classification.d.ts +78 -0
- package/src/lib/engine/thread-classification.js +224 -0
- package/src/lib/engine/thread-classification.js.map +1 -0
- package/src/lib/engine/thread-message.d.ts +80 -0
- package/src/lib/engine/thread-message.js +143 -0
- package/src/lib/engine/thread-message.js.map +1 -0
- package/src/lib/enrollment/enroll-people.d.ts +81 -0
- package/src/lib/enrollment/enroll-people.js +250 -0
- package/src/lib/enrollment/enroll-people.js.map +1 -0
- package/src/lib/enrollment/gate-lookups.d.ts +32 -0
- package/src/lib/enrollment/gate-lookups.js +154 -0
- package/src/lib/enrollment/gate-lookups.js.map +1 -0
- package/src/lib/mailboxes/mailbox-api.d.ts +126 -0
- package/src/lib/mailboxes/mailbox-api.js +66 -0
- package/src/lib/mailboxes/mailbox-api.js.map +1 -0
- package/src/lib/mailboxes/mailbox-credentials.d.ts +95 -0
- package/src/lib/mailboxes/mailbox-credentials.js +102 -0
- package/src/lib/mailboxes/mailbox-credentials.js.map +1 -0
- package/src/lib/mailboxes/mailbox-erasure.d.ts +69 -0
- package/src/lib/mailboxes/mailbox-erasure.js +148 -0
- package/src/lib/mailboxes/mailbox-erasure.js.map +1 -0
- package/src/lib/mailboxes/mailbox-gate.d.ts +75 -0
- package/src/lib/mailboxes/mailbox-gate.js +98 -0
- package/src/lib/mailboxes/mailbox-gate.js.map +1 -0
- package/src/lib/mailboxes/mailbox-revoke.d.ts +59 -0
- package/src/lib/mailboxes/mailbox-revoke.js +41 -0
- package/src/lib/mailboxes/mailbox-revoke.js.map +1 -0
- package/src/lib/mailboxes/mailbox-routes.d.ts +119 -0
- package/src/lib/mailboxes/mailbox-routes.js +605 -0
- package/src/lib/mailboxes/mailbox-routes.js.map +1 -0
- package/src/lib/mailboxes/mailbox-settings.d.ts +110 -0
- package/src/lib/mailboxes/mailbox-settings.js +212 -0
- package/src/lib/mailboxes/mailbox-settings.js.map +1 -0
- package/src/lib/mailboxes/mailbox-transport.d.ts +64 -0
- package/src/lib/mailboxes/mailbox-transport.js +94 -0
- package/src/lib/mailboxes/mailbox-transport.js.map +1 -0
- package/src/lib/mailboxes/oauth-redirect.d.ts +41 -0
- package/src/lib/mailboxes/oauth-redirect.js +63 -0
- package/src/lib/mailboxes/oauth-redirect.js.map +1 -0
- package/src/lib/mailboxes/oauth-state.d.ts +152 -0
- package/src/lib/mailboxes/oauth-state.js +212 -0
- package/src/lib/mailboxes/oauth-state.js.map +1 -0
- package/src/lib/mailboxes/outreach-config.d.ts +64 -0
- package/src/lib/mailboxes/outreach-config.js +77 -0
- package/src/lib/mailboxes/outreach-config.js.map +1 -0
- package/src/lib/mailboxes/register-mailbox-routes.d.ts +47 -0
- package/src/lib/mailboxes/register-mailbox-routes.js +135 -0
- package/src/lib/mailboxes/register-mailbox-routes.js.map +1 -0
- package/src/lib/model/compliance-settings.d.ts +78 -0
- package/src/lib/model/compliance-settings.js +155 -0
- package/src/lib/model/compliance-settings.js.map +1 -0
- package/src/lib/model/outreach-api.d.ts +210 -0
- package/src/lib/model/outreach-api.js +28 -0
- package/src/lib/model/outreach-api.js.map +1 -0
- package/src/lib/model/outreach.types.d.ts +563 -0
- package/src/lib/model/outreach.types.js +225 -0
- package/src/lib/model/outreach.types.js.map +1 -0
- package/src/lib/model/sequence-draft.d.ts +84 -0
- package/src/lib/model/sequence-draft.js +239 -0
- package/src/lib/model/sequence-draft.js.map +1 -0
- package/src/lib/model/step-preview.d.ts +45 -0
- package/src/lib/model/step-preview.js +98 -0
- package/src/lib/model/step-preview.js.map +1 -0
- package/src/lib/model/stored-records.d.ts +23 -0
- package/src/lib/model/stored-records.js +84 -0
- package/src/lib/model/stored-records.js.map +1 -0
- package/src/lib/plugin.d.ts +46 -0
- package/src/lib/plugin.js +86 -0
- package/src/lib/plugin.js.map +1 -0
- package/src/lib/routes/enroll-routes.d.ts +64 -0
- package/src/lib/routes/enroll-routes.js +350 -0
- package/src/lib/routes/enroll-routes.js.map +1 -0
- package/src/lib/routes/enrollment-routes.d.ts +19 -0
- package/src/lib/routes/enrollment-routes.js +171 -0
- package/src/lib/routes/enrollment-routes.js.map +1 -0
- package/src/lib/routes/platform-deps.d.ts +51 -0
- package/src/lib/routes/platform-deps.js +66 -0
- package/src/lib/routes/platform-deps.js.map +1 -0
- package/src/lib/routes/preview-routes.d.ts +33 -0
- package/src/lib/routes/preview-routes.js +117 -0
- package/src/lib/routes/preview-routes.js.map +1 -0
- package/src/lib/routes/register-routes.d.ts +22 -0
- package/src/lib/routes/register-routes.js +92 -0
- package/src/lib/routes/register-routes.js.map +1 -0
- package/src/lib/routes/route-deps.d.ts +49 -0
- package/src/lib/routes/route-deps.js +27 -0
- package/src/lib/routes/route-deps.js.map +1 -0
- package/src/lib/routes/route-gate.d.ts +94 -0
- package/src/lib/routes/route-gate.js +87 -0
- package/src/lib/routes/route-gate.js.map +1 -0
- package/src/lib/routes/route-http.d.ts +32 -0
- package/src/lib/routes/route-http.js +62 -0
- package/src/lib/routes/route-http.js.map +1 -0
- package/src/lib/routes/sequence-routes.d.ts +32 -0
- package/src/lib/routes/sequence-routes.js +359 -0
- package/src/lib/routes/sequence-routes.js.map +1 -0
- package/src/lib/routes/settings-routes.d.ts +30 -0
- package/src/lib/routes/settings-routes.js +73 -0
- package/src/lib/routes/settings-routes.js.map +1 -0
- package/src/lib/runtime/enrollment-events.d.ts +86 -0
- package/src/lib/runtime/enrollment-events.js +141 -0
- package/src/lib/runtime/enrollment-events.js.map +1 -0
- package/src/lib/runtime/fixtures/fake-gmail.d.ts +121 -0
- package/src/lib/runtime/fixtures/fake-gmail.js +312 -0
- package/src/lib/runtime/fixtures/fake-gmail.js.map +1 -0
- package/src/lib/runtime/mailbox-health-store.d.ts +47 -0
- package/src/lib/runtime/mailbox-health-store.js +168 -0
- package/src/lib/runtime/mailbox-health-store.js.map +1 -0
- package/src/lib/runtime/person-erasure.d.ts +39 -0
- package/src/lib/runtime/person-erasure.js +57 -0
- package/src/lib/runtime/person-erasure.js.map +1 -0
- package/src/lib/runtime/platform-runtime-deps.d.ts +29 -0
- package/src/lib/runtime/platform-runtime-deps.js +98 -0
- package/src/lib/runtime/platform-runtime-deps.js.map +1 -0
- package/src/lib/runtime/runtime-deps.d.ts +77 -0
- package/src/lib/runtime/runtime-deps.js +18 -0
- package/src/lib/runtime/runtime-deps.js.map +1 -0
- package/src/lib/runtime/send-job.d.ts +42 -0
- package/src/lib/runtime/send-job.js +780 -0
- package/src/lib/runtime/send-job.js.map +1 -0
- package/src/lib/runtime/sync-job.d.ts +41 -0
- package/src/lib/runtime/sync-job.js +480 -0
- package/src/lib/runtime/sync-job.js.map +1 -0
- package/src/lib/runtime/timeline.d.ts +67 -0
- package/src/lib/runtime/timeline.js +93 -0
- package/src/lib/runtime/timeline.js.map +1 -0
- package/src/lib/runtime/unsubscribe-link.d.ts +75 -0
- package/src/lib/runtime/unsubscribe-link.js +121 -0
- package/src/lib/runtime/unsubscribe-link.js.map +1 -0
- package/src/lib/runtime/unsubscribe-route.d.ts +24 -0
- package/src/lib/runtime/unsubscribe-route.js +103 -0
- package/src/lib/runtime/unsubscribe-route.js.map +1 -0
- package/src/lib/server.d.ts +53 -0
- package/src/lib/server.js +94 -0
- package/src/lib/server.js.map +1 -0
- package/src/lib/storage/compliance-settings-store.d.ts +44 -0
- package/src/lib/storage/compliance-settings-store.js +78 -0
- package/src/lib/storage/compliance-settings-store.js.map +1 -0
- package/src/lib/storage/do-not-contact-store.d.ts +60 -0
- package/src/lib/storage/do-not-contact-store.js +168 -0
- package/src/lib/storage/do-not-contact-store.js.map +1 -0
- package/src/lib/storage/outreach-records.d.ts +33 -0
- package/src/lib/storage/outreach-records.js +35 -0
- package/src/lib/storage/outreach-records.js.map +1 -0
- package/src/lib/subprocessors.d.ts +48 -0
- package/src/lib/subprocessors.js +70 -0
- package/src/lib/subprocessors.js.map +1 -0
- package/src/lib/transport/gmail-client.d.ts +145 -0
- package/src/lib/transport/gmail-client.js +229 -0
- package/src/lib/transport/gmail-client.js.map +1 -0
- package/src/lib/transport/gmail-errors.d.ts +84 -0
- package/src/lib/transport/gmail-errors.js +138 -0
- package/src/lib/transport/gmail-errors.js.map +1 -0
- package/src/lib/transport/google-oauth.d.ts +138 -0
- package/src/lib/transport/google-oauth.js +247 -0
- package/src/lib/transport/google-oauth.js.map +1 -0
- package/src/lib/transport/http.d.ts +53 -0
- package/src/lib/transport/http.js +79 -0
- package/src/lib/transport/http.js.map +1 -0
- package/src/lib/transport/rfc5322.d.ts +116 -0
- package/src/lib/transport/rfc5322.js +330 -0
- package/src/lib/transport/rfc5322.js.map +1 -0
- package/src/lib/transport/send-message.d.ts +69 -0
- package/src/lib/transport/send-message.js +77 -0
- package/src/lib/transport/send-message.js.map +1 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { needsReseal, openSecret, sealSecret } from "@aglyn/shared-util-tools/secret-box";
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { OUTREACH_COLLECTIONS } from "../model/outreach.types.js";
|
|
19
|
+
/** The context a mailbox's refresh token is sealed under. */ export function refreshTokenSealContext(mailboxId) {
|
|
20
|
+
return `${OUTREACH_COLLECTIONS.mailboxCredentials}/${mailboxId}#refreshToken`;
|
|
21
|
+
}
|
|
22
|
+
/** Seals a refresh token for one mailbox under the keyring's current key. */ export function sealMailboxRefreshToken(refreshToken, mailboxId, keyring) {
|
|
23
|
+
return {
|
|
24
|
+
sealedRefreshToken: sealSecret(refreshToken, keyring.current, {
|
|
25
|
+
context: refreshTokenSealContext(mailboxId)
|
|
26
|
+
}),
|
|
27
|
+
tokenKeyId: keyring.current.id
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Opens a stored credential's refresh token. Throws `SecretBoxError` when it
|
|
32
|
+
* cannot be opened — a missing or rotated-away key, or a tampered value.
|
|
33
|
+
*/ export function openMailboxRefreshToken(credential, keyring) {
|
|
34
|
+
const opened = openSecret(credential.sealedRefreshToken, keyring, {
|
|
35
|
+
context: refreshTokenSealContext(credential.mailboxId)
|
|
36
|
+
});
|
|
37
|
+
return {
|
|
38
|
+
refreshToken: opened.plaintext,
|
|
39
|
+
needsReseal: needsReseal(opened, keyring)
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The id a member's mailbox for one Google account has in one organization.
|
|
44
|
+
*
|
|
45
|
+
* Derived rather than random, so connecting the same account again updates
|
|
46
|
+
* the same mailbox instead of adding a second one, and two members — or two
|
|
47
|
+
* organizations — connecting one account never share an id. The credential
|
|
48
|
+
* collection is top-level, keyed by this id, so it must be unique across
|
|
49
|
+
* every organization, which is why the org is part of it.
|
|
50
|
+
*/ export function outreachMailboxId(orgId, uid, providerAccountId) {
|
|
51
|
+
const hash = createHash('sha256').update(`${orgId}\n${uid}\ngoogle\n${providerAccountId}`).digest('base64url');
|
|
52
|
+
return `gm_${hash.slice(0, 24)}`;
|
|
53
|
+
}
|
|
54
|
+
export function mailboxCredentialsRef(firestore, mailboxId) {
|
|
55
|
+
return firestore.collection(OUTREACH_COLLECTIONS.mailboxCredentials).doc(mailboxId);
|
|
56
|
+
}
|
|
57
|
+
export function mailboxRef(firestore, orgId, mailboxId) {
|
|
58
|
+
return firestore.collection('orgs').doc(orgId).collection(OUTREACH_COLLECTIONS.mailboxes).doc(mailboxId);
|
|
59
|
+
}
|
|
60
|
+
/** A stored credential read defensively, or `null` when it is not one. */ export function readMailboxCredentials(data) {
|
|
61
|
+
var _record_id, _record_connectedByUid, _record_providerAccountId, _record_email, _record_tokenKeyId;
|
|
62
|
+
const record = data != null ? data : null;
|
|
63
|
+
if (!record || record.provider !== 'google' || typeof record.mailboxId !== 'string' || typeof record.orgId !== 'string' || typeof record.sealedRefreshToken !== 'string' || !record.sealedRefreshToken) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
id: String((_record_id = record.id) != null ? _record_id : record.mailboxId),
|
|
68
|
+
orgId: record.orgId,
|
|
69
|
+
mailboxId: record.mailboxId,
|
|
70
|
+
provider: 'google',
|
|
71
|
+
connectedByUid: String((_record_connectedByUid = record.connectedByUid) != null ? _record_connectedByUid : ''),
|
|
72
|
+
providerAccountId: String((_record_providerAccountId = record.providerAccountId) != null ? _record_providerAccountId : ''),
|
|
73
|
+
email: String((_record_email = record.email) != null ? _record_email : ''),
|
|
74
|
+
scopes: Array.isArray(record.scopes) ? record.scopes.map(String) : [],
|
|
75
|
+
sealedRefreshToken: record.sealedRefreshToken,
|
|
76
|
+
tokenKeyId: String((_record_tokenKeyId = record.tokenKeyId) != null ? _record_tokenKeyId : ''),
|
|
77
|
+
createdAtMs: Number(record.createdAtMs) || 0,
|
|
78
|
+
updatedAtMs: Number(record.updatedAtMs) || 0
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* How many OTHER stored credentials hold a grant for the same Google account.
|
|
83
|
+
*
|
|
84
|
+
* Google's revocation ends the whole grant this client holds for an account,
|
|
85
|
+
* not one token, so revoking while another mailbox still uses the account —
|
|
86
|
+
* the same shared inbox connected by two members, or one account connected
|
|
87
|
+
* in two organizations — would silently break that mailbox. A disconnect or
|
|
88
|
+
* an erasure asks this first and leaves the grant alone while it is shared.
|
|
89
|
+
*
|
|
90
|
+
* An erasure names what it is ending anyway — a whole organization's
|
|
91
|
+
* credentials, or every one of a person's — and those are not counted.
|
|
92
|
+
*/ export async function countOtherCredentialsForAccount(firestore, input) {
|
|
93
|
+
if (!input.providerAccountId) return 0;
|
|
94
|
+
const snapshot = await firestore.collection(OUTREACH_COLLECTIONS.mailboxCredentials).where('providerAccountId', '==', input.providerAccountId).limit(20).get();
|
|
95
|
+
return snapshot.docs.filter((doc)=>{
|
|
96
|
+
var _input_excludeMailboxIds;
|
|
97
|
+
if (doc.id === input.mailboxId || ((_input_excludeMailboxIds = input.excludeMailboxIds) == null ? void 0 : _input_excludeMailboxIds.has(doc.id))) return false;
|
|
98
|
+
return !input.excludeOrgId || doc.get('orgId') !== input.excludeOrgId;
|
|
99
|
+
}).length;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
//# sourceMappingURL=mailbox-credentials.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/mailbox-credentials.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 needsReseal,\n openSecret,\n sealSecret,\n type SecretBoxKeyring,\n} from '@aglyn/shared-util-tools/secret-box'\nimport { createHash } from 'node:crypto'\nimport {\n OUTREACH_COLLECTIONS,\n type OutreachMailboxCredentials,\n} from '../model/outreach.types'\n\n/**\n * A MAILBOX'S GRANT AT REST (AGL-2978): `outreachMailboxCredentials/{mailboxId}`.\n *\n * The refresh token is the only secret, and it is stored only sealed —\n * AES-256-GCM under `OUTREACH_TOKEN_KEY` through the shared secret box, bound\n * to this document by the seal's context, so a sealed token copied into\n * another mailbox's credential refuses to open there. Every other field is\n * bookkeeping a reader needs without the key: which account, which scopes,\n * which member, which key sealed it.\n *\n * Sealed fields carry `token` in their names. The personal-data export\n * redacts by field name first, and a neutral name would be judged by its\n * value's shape alone.\n */\n\n/** A connected Google mailbox's stored credential. */\nexport interface OutreachGoogleMailboxCredentials extends OutreachMailboxCredentials {\n provider: 'google'\n /** The member who connected the mailbox. */\n connectedByUid: string\n /** Google's stable account id (`sub`), so one account's grants can be found. */\n providerAccountId: string\n /** The account's address, as Google verified it. */\n email: string\n /** The scopes Google granted, as it listed them. */\n scopes: string[]\n /** The refresh token, sealed — see the module comment. */\n sealedRefreshToken: string\n /** The id of the key that sealed it, for rotation. */\n tokenKeyId: string\n}\n\n/** The context a mailbox's refresh token is sealed under. */\nexport function refreshTokenSealContext(mailboxId: string): string {\n return `${OUTREACH_COLLECTIONS.mailboxCredentials}/${mailboxId}#refreshToken`\n}\n\n/** Seals a refresh token for one mailbox under the keyring's current key. */\nexport function sealMailboxRefreshToken(\n refreshToken: string,\n mailboxId: string,\n keyring: SecretBoxKeyring,\n): { sealedRefreshToken: string; tokenKeyId: string } {\n return {\n sealedRefreshToken: sealSecret(refreshToken, keyring.current, {\n context: refreshTokenSealContext(mailboxId),\n }),\n tokenKeyId: keyring.current.id,\n }\n}\n\n/**\n * Opens a stored credential's refresh token. Throws `SecretBoxError` when it\n * cannot be opened — a missing or rotated-away key, or a tampered value.\n */\nexport function openMailboxRefreshToken(\n credential: Pick<OutreachGoogleMailboxCredentials, 'mailboxId' | 'sealedRefreshToken'>,\n keyring: SecretBoxKeyring,\n): { refreshToken: string; needsReseal: boolean } {\n const opened = openSecret(credential.sealedRefreshToken, keyring, {\n context: refreshTokenSealContext(credential.mailboxId),\n })\n return { refreshToken: opened.plaintext, needsReseal: needsReseal(opened, keyring) }\n}\n\n/**\n * The id a member's mailbox for one Google account has in one organization.\n *\n * Derived rather than random, so connecting the same account again updates\n * the same mailbox instead of adding a second one, and two members — or two\n * organizations — connecting one account never share an id. The credential\n * collection is top-level, keyed by this id, so it must be unique across\n * every organization, which is why the org is part of it.\n */\nexport function outreachMailboxId(orgId: string, uid: string, providerAccountId: string): string {\n const hash = createHash('sha256').update(`${orgId}\\n${uid}\\ngoogle\\n${providerAccountId}`).digest('base64url')\n return `gm_${hash.slice(0, 24)}`\n}\n\nexport function mailboxCredentialsRef(firestore: FirebaseFirestore.Firestore, mailboxId: string) {\n return firestore.collection(OUTREACH_COLLECTIONS.mailboxCredentials).doc(mailboxId)\n}\n\nexport function mailboxRef(firestore: FirebaseFirestore.Firestore, orgId: string, mailboxId: string) {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(OUTREACH_COLLECTIONS.mailboxes)\n .doc(mailboxId)\n}\n\n/** A stored credential read defensively, or `null` when it is not one. */\nexport function readMailboxCredentials(data: unknown): OutreachGoogleMailboxCredentials | null {\n const record = (data ?? null) as Partial<OutreachGoogleMailboxCredentials> | null\n if (\n !record ||\n record.provider !== 'google' ||\n typeof record.mailboxId !== 'string' ||\n typeof record.orgId !== 'string' ||\n typeof record.sealedRefreshToken !== 'string' ||\n !record.sealedRefreshToken\n ) {\n return null\n }\n return {\n id: String(record.id ?? record.mailboxId),\n orgId: record.orgId,\n mailboxId: record.mailboxId,\n provider: 'google',\n connectedByUid: String(record.connectedByUid ?? ''),\n providerAccountId: String(record.providerAccountId ?? ''),\n email: String(record.email ?? ''),\n scopes: Array.isArray(record.scopes) ? record.scopes.map(String) : [],\n sealedRefreshToken: record.sealedRefreshToken,\n tokenKeyId: String(record.tokenKeyId ?? ''),\n createdAtMs: Number(record.createdAtMs) || 0,\n updatedAtMs: Number(record.updatedAtMs) || 0,\n }\n}\n\n/**\n * How many OTHER stored credentials hold a grant for the same Google account.\n *\n * Google's revocation ends the whole grant this client holds for an account,\n * not one token, so revoking while another mailbox still uses the account —\n * the same shared inbox connected by two members, or one account connected\n * in two organizations — would silently break that mailbox. A disconnect or\n * an erasure asks this first and leaves the grant alone while it is shared.\n *\n * An erasure names what it is ending anyway — a whole organization's\n * credentials, or every one of a person's — and those are not counted.\n */\nexport async function countOtherCredentialsForAccount(\n firestore: FirebaseFirestore.Firestore,\n input: {\n providerAccountId: string\n mailboxId: string\n excludeOrgId?: string\n excludeMailboxIds?: ReadonlySet<string>\n },\n): Promise<number> {\n if (!input.providerAccountId) return 0\n const snapshot = await firestore\n .collection(OUTREACH_COLLECTIONS.mailboxCredentials)\n .where('providerAccountId', '==', input.providerAccountId)\n .limit(20)\n .get()\n return snapshot.docs.filter((doc) => {\n if (doc.id === input.mailboxId || input.excludeMailboxIds?.has(doc.id)) return false\n return !input.excludeOrgId || doc.get('orgId') !== input.excludeOrgId\n }).length\n}\n"],"names":["needsReseal","openSecret","sealSecret","createHash","OUTREACH_COLLECTIONS","refreshTokenSealContext","mailboxId","mailboxCredentials","sealMailboxRefreshToken","refreshToken","keyring","sealedRefreshToken","current","context","tokenKeyId","id","openMailboxRefreshToken","credential","opened","plaintext","outreachMailboxId","orgId","uid","providerAccountId","hash","update","digest","slice","mailboxCredentialsRef","firestore","collection","doc","mailboxRef","mailboxes","readMailboxCredentials","data","record","provider","String","connectedByUid","email","scopes","Array","isArray","map","createdAtMs","Number","updatedAtMs","countOtherCredentialsForAccount","input","snapshot","where","limit","get","docs","filter","excludeMailboxIds","has","excludeOrgId","length"],"mappings":"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"}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { PluginOrgEraser } from '@aglyn/aglyn/plugin-manager/plugin-org-erasure';
|
|
18
|
+
import type { PluginUserEraser } from '@aglyn/aglyn/plugin-manager/plugin-user-erasure';
|
|
19
|
+
import { type OutreachRevokeDeps } from './mailbox-revoke';
|
|
20
|
+
/**
|
|
21
|
+
* OUTREACH'S SHARE OF AN ERASURE (AGL-2978).
|
|
22
|
+
*
|
|
23
|
+
* The workspace erasure deletes every stored Outreach grant on its own —
|
|
24
|
+
* `outreachMailboxCredentials` by its `orgId` field, and the mailboxes and
|
|
25
|
+
* pending connects with the organization's tree — in any process, whether
|
|
26
|
+
* or not this plugin is loaded. Deleting the platform's copy leaves the
|
|
27
|
+
* grant alive at Google, though: listed in the rep's account as an app with
|
|
28
|
+
* access to their mail until they remove it by hand. So Outreach registers a
|
|
29
|
+
* workspace eraser (`plugin-org-erasure`) that revokes each grant first,
|
|
30
|
+
* while the stored credential still exists to revoke it with. It deletes
|
|
31
|
+
* nothing itself; the erasure's own sweep does that right after.
|
|
32
|
+
*
|
|
33
|
+
* An ACCOUNT erasure (`plugin-user-erasure`, AGL-3106) reaches none of it on
|
|
34
|
+
* its own: a person's mailbox lives under the organization and their grant
|
|
35
|
+
* in a top-level collection, and the erasure removes the person, not the
|
|
36
|
+
* organizations they belonged to. So the account eraser below revokes every
|
|
37
|
+
* grant the person connected and deletes it, with their mailboxes and their
|
|
38
|
+
* pending connects — their Gmail address and a token that sends as them are
|
|
39
|
+
* about the person, and must not outlive them.
|
|
40
|
+
*
|
|
41
|
+
* Both erasers are registered from the console-only declarations entry:
|
|
42
|
+
* revoking opens a sealed token with `OUTREACH_TOKEN_KEY`, which only the
|
|
43
|
+
* console holds.
|
|
44
|
+
*/
|
|
45
|
+
export interface OutreachErasureDeps extends OutreachRevokeDeps {
|
|
46
|
+
firestore(): FirebaseFirestore.Firestore;
|
|
47
|
+
}
|
|
48
|
+
/** The platform's own dependencies. Specs build their own. */
|
|
49
|
+
export declare function defaultOutreachErasureDeps(): OutreachErasureDeps;
|
|
50
|
+
/**
|
|
51
|
+
* The workspace eraser: revoke, at Google, every grant the organization
|
|
52
|
+
* holds. A grant another organization still uses for the same Google
|
|
53
|
+
* account is kept (`kept`), because revoking it would cut that
|
|
54
|
+
* organization's mailbox off too. A plan counts the grants and touches
|
|
55
|
+
* nothing, so its revocation figures are `null` — not measured, not zero.
|
|
56
|
+
*/
|
|
57
|
+
export declare function createOutreachOrgEraser(deps: OutreachErasureDeps): PluginOrgEraser;
|
|
58
|
+
/**
|
|
59
|
+
* The account eraser (AGL-3106): every mailbox the person connected, in the
|
|
60
|
+
* organizations they belonged to and in any they had already left, revoked
|
|
61
|
+
* at Google and deleted with its stored grant; and their pending connects.
|
|
62
|
+
*
|
|
63
|
+
* A grant is kept at Google when a TEAMMATE still uses the same Google
|
|
64
|
+
* account — a shared inbox two members connected — because revoking it would
|
|
65
|
+
* cut the teammate's mailbox off; the erased person's own copy is deleted
|
|
66
|
+
* either way. Every other copy the person holds is being erased too, so it
|
|
67
|
+
* does not count as a use.
|
|
68
|
+
*/
|
|
69
|
+
export declare function createOutreachUserEraser(deps: OutreachErasureDeps): PluginUserEraser;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
|
+
import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
|
|
18
|
+
import { OUTREACH_COLLECTIONS } from "../model/outreach.types.js";
|
|
19
|
+
import { mailboxCredentialsRef, mailboxRef, readMailboxCredentials } from "./mailbox-credentials.js";
|
|
20
|
+
import { revokeMailboxGrant } from "./mailbox-revoke.js";
|
|
21
|
+
import { outreachOAuthStateRef } from "./oauth-state.js";
|
|
22
|
+
import { readOutreachGoogleConfig } from "./outreach-config.js";
|
|
23
|
+
/** The platform's own dependencies. Specs build their own. */ export function defaultOutreachErasureDeps() {
|
|
24
|
+
return {
|
|
25
|
+
firestore: ()=>firebaseAdmin.app().firestore(),
|
|
26
|
+
readConfig: readOutreachGoogleConfig,
|
|
27
|
+
transport: {}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const emptyTally = ()=>({
|
|
31
|
+
revoked: 0,
|
|
32
|
+
alreadyInvalid: 0,
|
|
33
|
+
kept: 0,
|
|
34
|
+
failed: 0
|
|
35
|
+
});
|
|
36
|
+
function count(tally, outcome) {
|
|
37
|
+
if (outcome === 'revoked') tally.revoked += 1;
|
|
38
|
+
else if (outcome === 'already-invalid') tally.alreadyInvalid += 1;
|
|
39
|
+
else if (outcome === 'kept-for-other-mailbox') tally.kept += 1;
|
|
40
|
+
else tally.failed += 1;
|
|
41
|
+
}
|
|
42
|
+
/** Firestore refuses a batch of more than 500 writes. */ const BATCH_LIMIT = 450;
|
|
43
|
+
/** Deletes every reference, in batches under Firestore's limit. */ async function deleteAll(firestore, refs) {
|
|
44
|
+
for(let start = 0; start < refs.length; start += BATCH_LIMIT){
|
|
45
|
+
const batch = firestore.batch();
|
|
46
|
+
for (const ref of refs.slice(start, start + BATCH_LIMIT))batch.delete(ref);
|
|
47
|
+
await batch.commit();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The workspace eraser: revoke, at Google, every grant the organization
|
|
52
|
+
* holds. A grant another organization still uses for the same Google
|
|
53
|
+
* account is kept (`kept`), because revoking it would cut that
|
|
54
|
+
* organization's mailbox off too. A plan counts the grants and touches
|
|
55
|
+
* nothing, so its revocation figures are `null` — not measured, not zero.
|
|
56
|
+
*/ export function createOutreachOrgEraser(deps) {
|
|
57
|
+
return async ({ orgId, dryRun })=>{
|
|
58
|
+
const firestore = deps.firestore();
|
|
59
|
+
const rows = await firestore.collection(OUTREACH_COLLECTIONS.mailboxCredentials).where('orgId', '==', orgId).get();
|
|
60
|
+
if (dryRun) {
|
|
61
|
+
return {
|
|
62
|
+
grants: rows.size,
|
|
63
|
+
revoked: null,
|
|
64
|
+
alreadyInvalid: null,
|
|
65
|
+
kept: null,
|
|
66
|
+
failed: null
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const tally = emptyTally();
|
|
70
|
+
for (const doc of rows.docs){
|
|
71
|
+
const credential = readMailboxCredentials(doc.data());
|
|
72
|
+
count(tally, credential ? await revokeMailboxGrant(firestore, credential, deps, {
|
|
73
|
+
excludeOrgId: orgId
|
|
74
|
+
}) : 'failed');
|
|
75
|
+
}
|
|
76
|
+
return _extends({
|
|
77
|
+
grants: rows.size
|
|
78
|
+
}, tally);
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The account eraser (AGL-3106): every mailbox the person connected, in the
|
|
83
|
+
* organizations they belonged to and in any they had already left, revoked
|
|
84
|
+
* at Google and deleted with its stored grant; and their pending connects.
|
|
85
|
+
*
|
|
86
|
+
* A grant is kept at Google when a TEAMMATE still uses the same Google
|
|
87
|
+
* account — a shared inbox two members connected — because revoking it would
|
|
88
|
+
* cut the teammate's mailbox off; the erased person's own copy is deleted
|
|
89
|
+
* either way. Every other copy the person holds is being erased too, so it
|
|
90
|
+
* does not count as a use.
|
|
91
|
+
*/ export function createOutreachUserEraser(deps) {
|
|
92
|
+
return async ({ uid, orgIds })=>{
|
|
93
|
+
const firestore = deps.firestore();
|
|
94
|
+
// By the person, across every organization — including one they have
|
|
95
|
+
// already left, which `orgIds` no longer names.
|
|
96
|
+
const credentials = await firestore.collection(OUTREACH_COLLECTIONS.mailboxCredentials).where('connectedByUid', '==', uid).get();
|
|
97
|
+
const mailboxes = new Map();
|
|
98
|
+
const stored = [];
|
|
99
|
+
for (const doc of credentials.docs){
|
|
100
|
+
var _ref, _doc_get;
|
|
101
|
+
const credential = readMailboxCredentials(doc.data());
|
|
102
|
+
const orgId = (_ref = credential == null ? void 0 : credential.orgId) != null ? _ref : String((_doc_get = doc.get('orgId')) != null ? _doc_get : '');
|
|
103
|
+
if (credential) stored.push(credential);
|
|
104
|
+
if (orgId) mailboxes.set(`${orgId}/${doc.id}`, {
|
|
105
|
+
orgId,
|
|
106
|
+
mailboxId: doc.id
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
// A mailbox whose grant is already gone still names the person's
|
|
110
|
+
// address; find those under each organization the person was in.
|
|
111
|
+
for (const orgId of orgIds){
|
|
112
|
+
const connected = await firestore.collection('orgs').doc(orgId).collection(OUTREACH_COLLECTIONS.mailboxes).where('connectedByUid', '==', uid).get();
|
|
113
|
+
for (const doc of connected.docs)mailboxes.set(`${orgId}/${doc.id}`, {
|
|
114
|
+
orgId,
|
|
115
|
+
mailboxId: doc.id
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const theirs = new Set(credentials.docs.map((doc)=>doc.id));
|
|
119
|
+
const tally = emptyTally();
|
|
120
|
+
for (const credential of stored){
|
|
121
|
+
count(tally, await revokeMailboxGrant(firestore, credential, deps, {
|
|
122
|
+
excludeMailboxIds: theirs
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
tally.failed += credentials.size - stored.length;
|
|
126
|
+
const orgsWithPending = new Set([
|
|
127
|
+
...orgIds,
|
|
128
|
+
...[
|
|
129
|
+
...mailboxes.values()
|
|
130
|
+
].map((entry)=>entry.orgId)
|
|
131
|
+
]);
|
|
132
|
+
await deleteAll(firestore, [
|
|
133
|
+
...credentials.docs.map((doc)=>mailboxCredentialsRef(firestore, doc.id)),
|
|
134
|
+
...[
|
|
135
|
+
...mailboxes.values()
|
|
136
|
+
].map(({ orgId, mailboxId })=>mailboxRef(firestore, orgId, mailboxId)),
|
|
137
|
+
...[
|
|
138
|
+
...orgsWithPending
|
|
139
|
+
].map((orgId)=>outreachOAuthStateRef(firestore, orgId, uid))
|
|
140
|
+
]);
|
|
141
|
+
return _extends({
|
|
142
|
+
mailboxes: mailboxes.size,
|
|
143
|
+
grants: credentials.size
|
|
144
|
+
}, tally);
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
//# sourceMappingURL=mailbox-erasure.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/mailbox-erasure.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 type {\n PluginOrgEraser,\n PluginOrgErasureReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-org-erasure'\nimport type {\n PluginUserEraser,\n PluginUserErasureReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-user-erasure'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { OUTREACH_COLLECTIONS } from '../model/outreach.types'\nimport {\n mailboxCredentialsRef,\n mailboxRef,\n readMailboxCredentials,\n type OutreachGoogleMailboxCredentials,\n} from './mailbox-credentials'\nimport {\n revokeMailboxGrant,\n type OutreachGrantRevocation,\n type OutreachRevokeDeps,\n} from './mailbox-revoke'\nimport { outreachOAuthStateRef } from './oauth-state'\nimport { readOutreachGoogleConfig } from './outreach-config'\n\n/**\n * OUTREACH'S SHARE OF AN ERASURE (AGL-2978).\n *\n * The workspace erasure deletes every stored Outreach grant on its own —\n * `outreachMailboxCredentials` by its `orgId` field, and the mailboxes and\n * pending connects with the organization's tree — in any process, whether\n * or not this plugin is loaded. Deleting the platform's copy leaves the\n * grant alive at Google, though: listed in the rep's account as an app with\n * access to their mail until they remove it by hand. So Outreach registers a\n * workspace eraser (`plugin-org-erasure`) that revokes each grant first,\n * while the stored credential still exists to revoke it with. It deletes\n * nothing itself; the erasure's own sweep does that right after.\n *\n * An ACCOUNT erasure (`plugin-user-erasure`, AGL-3106) reaches none of it on\n * its own: a person's mailbox lives under the organization and their grant\n * in a top-level collection, and the erasure removes the person, not the\n * organizations they belonged to. So the account eraser below revokes every\n * grant the person connected and deletes it, with their mailboxes and their\n * pending connects — their Gmail address and a token that sends as them are\n * about the person, and must not outlive them.\n *\n * Both erasers are registered from the console-only declarations entry:\n * revoking opens a sealed token with `OUTREACH_TOKEN_KEY`, which only the\n * console holds.\n */\n\nexport interface OutreachErasureDeps extends OutreachRevokeDeps {\n firestore(): FirebaseFirestore.Firestore\n}\n\n/** The platform's own dependencies. Specs build their own. */\nexport function defaultOutreachErasureDeps(): OutreachErasureDeps {\n return {\n firestore: () => firebaseAdmin.app().firestore(),\n readConfig: readOutreachGoogleConfig,\n transport: {},\n }\n}\n\n/** How many grants came to each end, for an erasure's audit record. */\ninterface RevocationTally {\n revoked: number\n alreadyInvalid: number\n kept: number\n failed: number\n}\n\nconst emptyTally = (): RevocationTally => ({ revoked: 0, alreadyInvalid: 0, kept: 0, failed: 0 })\n\nfunction count(tally: RevocationTally, outcome: OutreachGrantRevocation): void {\n if (outcome === 'revoked') tally.revoked += 1\n else if (outcome === 'already-invalid') tally.alreadyInvalid += 1\n else if (outcome === 'kept-for-other-mailbox') tally.kept += 1\n else tally.failed += 1\n}\n\n/** Firestore refuses a batch of more than 500 writes. */\nconst BATCH_LIMIT = 450\n\n/** Deletes every reference, in batches under Firestore's limit. */\nasync function deleteAll(\n firestore: FirebaseFirestore.Firestore,\n refs: readonly FirebaseFirestore.DocumentReference[],\n): Promise<void> {\n for (let start = 0; start < refs.length; start += BATCH_LIMIT) {\n const batch = firestore.batch()\n for (const ref of refs.slice(start, start + BATCH_LIMIT)) batch.delete(ref)\n await batch.commit()\n }\n}\n\n/**\n * The workspace eraser: revoke, at Google, every grant the organization\n * holds. A grant another organization still uses for the same Google\n * account is kept (`kept`), because revoking it would cut that\n * organization's mailbox off too. A plan counts the grants and touches\n * nothing, so its revocation figures are `null` — not measured, not zero.\n */\nexport function createOutreachOrgEraser(deps: OutreachErasureDeps): PluginOrgEraser {\n return async ({ orgId, dryRun }): Promise<PluginOrgErasureReport> => {\n const firestore = deps.firestore()\n const rows = await firestore\n .collection(OUTREACH_COLLECTIONS.mailboxCredentials)\n .where('orgId', '==', orgId)\n .get()\n if (dryRun) {\n return { grants: rows.size, revoked: null, alreadyInvalid: null, kept: null, failed: null }\n }\n const tally = emptyTally()\n for (const doc of rows.docs) {\n const credential = readMailboxCredentials(doc.data())\n count(\n tally,\n credential\n ? await revokeMailboxGrant(firestore, credential, deps, { excludeOrgId: orgId })\n : 'failed',\n )\n }\n return { grants: rows.size, ...tally }\n }\n}\n\n/**\n * The account eraser (AGL-3106): every mailbox the person connected, in the\n * organizations they belonged to and in any they had already left, revoked\n * at Google and deleted with its stored grant; and their pending connects.\n *\n * A grant is kept at Google when a TEAMMATE still uses the same Google\n * account — a shared inbox two members connected — because revoking it would\n * cut the teammate's mailbox off; the erased person's own copy is deleted\n * either way. Every other copy the person holds is being erased too, so it\n * does not count as a use.\n */\nexport function createOutreachUserEraser(deps: OutreachErasureDeps): PluginUserEraser {\n return async ({ uid, orgIds }): Promise<PluginUserErasureReport> => {\n const firestore = deps.firestore()\n // By the person, across every organization — including one they have\n // already left, which `orgIds` no longer names.\n const credentials = await firestore\n .collection(OUTREACH_COLLECTIONS.mailboxCredentials)\n .where('connectedByUid', '==', uid)\n .get()\n const mailboxes = new Map<string, { orgId: string; mailboxId: string }>()\n const stored: OutreachGoogleMailboxCredentials[] = []\n for (const doc of credentials.docs) {\n const credential = readMailboxCredentials(doc.data())\n const orgId = credential?.orgId ?? String(doc.get('orgId') ?? '')\n if (credential) stored.push(credential)\n if (orgId) mailboxes.set(`${orgId}/${doc.id}`, { orgId, mailboxId: doc.id })\n }\n // A mailbox whose grant is already gone still names the person's\n // address; find those under each organization the person was in.\n for (const orgId of orgIds) {\n const connected = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection(OUTREACH_COLLECTIONS.mailboxes)\n .where('connectedByUid', '==', uid)\n .get()\n for (const doc of connected.docs) mailboxes.set(`${orgId}/${doc.id}`, { orgId, mailboxId: doc.id })\n }\n\n const theirs = new Set(credentials.docs.map((doc) => doc.id))\n const tally = emptyTally()\n for (const credential of stored) {\n count(tally, await revokeMailboxGrant(firestore, credential, deps, { excludeMailboxIds: theirs }))\n }\n tally.failed += credentials.size - stored.length\n\n const orgsWithPending = new Set([...orgIds, ...[...mailboxes.values()].map((entry) => entry.orgId)])\n await deleteAll(firestore, [\n ...credentials.docs.map((doc) => mailboxCredentialsRef(firestore, doc.id)),\n ...[...mailboxes.values()].map(({ orgId, mailboxId }) => mailboxRef(firestore, orgId, mailboxId)),\n ...[...orgsWithPending].map((orgId) => outreachOAuthStateRef(firestore, orgId, uid)),\n ])\n\n return {\n mailboxes: mailboxes.size,\n grants: credentials.size,\n ...tally,\n }\n }\n}\n"],"names":["firebaseAdmin","OUTREACH_COLLECTIONS","mailboxCredentialsRef","mailboxRef","readMailboxCredentials","revokeMailboxGrant","outreachOAuthStateRef","readOutreachGoogleConfig","defaultOutreachErasureDeps","firestore","app","readConfig","transport","emptyTally","revoked","alreadyInvalid","kept","failed","count","tally","outcome","BATCH_LIMIT","deleteAll","refs","start","length","batch","ref","slice","delete","commit","createOutreachOrgEraser","deps","orgId","dryRun","rows","collection","mailboxCredentials","where","get","grants","size","doc","docs","credential","data","excludeOrgId","createOutreachUserEraser","uid","orgIds","credentials","mailboxes","Map","stored","String","push","set","id","mailboxId","connected","theirs","Set","map","excludeMailboxIds","orgsWithPending","values","entry"],"mappings":"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"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { DecodedIdToken } from 'firebase-admin/auth';
|
|
18
|
+
import type { OutreachApiRefusalReason } from './mailbox-api';
|
|
19
|
+
/**
|
|
20
|
+
* THE GATE EVERY AUTHENTICATED MAILBOX ROUTE CLIMBS (AGL-2978).
|
|
21
|
+
*
|
|
22
|
+
* The console dispatcher has already refused a request while
|
|
23
|
+
* `release_outreach` is off for the organization it names, an unverified
|
|
24
|
+
* account's token, a lockdown with no org in scope, and a caller over the
|
|
25
|
+
* console rate limit. What it cannot know is anything about the member, so
|
|
26
|
+
* each rung below is this plugin's:
|
|
27
|
+
*
|
|
28
|
+
* 401 no bearer token, or one the verifier refused
|
|
29
|
+
* 403 the account's address is unverified (an impersonation session is exempt)
|
|
30
|
+
* 400 the request did not name an organization
|
|
31
|
+
* 403 not a member of it, a site collaborator without org-wide reach, or
|
|
32
|
+
* without `outreach.use`
|
|
33
|
+
* 403 the organization does not hold the `outreach` entitlement
|
|
34
|
+
* 423 lockdown, now with the organization and the account in scope
|
|
35
|
+
*
|
|
36
|
+
* `resolveOrgPermissions` is always given the concrete org id. Called without
|
|
37
|
+
* one it resolves a caller with no org at all as that future org's owner,
|
|
38
|
+
* which is right for a fresh signup and wrong for every question asked here.
|
|
39
|
+
*/
|
|
40
|
+
export interface OutreachGateDeps {
|
|
41
|
+
verifyIdToken(idToken: string): Promise<DecodedIdToken>;
|
|
42
|
+
resolveOrgPermissions(uid: string, context: {
|
|
43
|
+
orgId: string;
|
|
44
|
+
}): Promise<{
|
|
45
|
+
orgId: string | null;
|
|
46
|
+
/** `null` when the account is not on the organization's roster. */
|
|
47
|
+
role: string | null;
|
|
48
|
+
isOwner: boolean;
|
|
49
|
+
orgWide: boolean;
|
|
50
|
+
permissions: Partial<Record<string, boolean | undefined>>;
|
|
51
|
+
}>;
|
|
52
|
+
readOrg(orgId: string): Promise<Record<string, unknown> | null>;
|
|
53
|
+
lockdownRefusal(options: {
|
|
54
|
+
request: Request;
|
|
55
|
+
staff: boolean;
|
|
56
|
+
uid: string;
|
|
57
|
+
org: Record<string, unknown>;
|
|
58
|
+
}): Promise<Response | null>;
|
|
59
|
+
}
|
|
60
|
+
export interface OutreachGateContext {
|
|
61
|
+
uid: string;
|
|
62
|
+
/** The account's sign-in address, lower-cased, when the token carries one. */
|
|
63
|
+
email: string | null;
|
|
64
|
+
/** The account's name, when the token carries one. */
|
|
65
|
+
name: string | null;
|
|
66
|
+
staff: boolean;
|
|
67
|
+
orgId: string;
|
|
68
|
+
org: Record<string, unknown>;
|
|
69
|
+
/** An owner or admin of the organization, who may manage every mailbox. */
|
|
70
|
+
isOrgAdmin: boolean;
|
|
71
|
+
}
|
|
72
|
+
/** A refusal in the one shape every mailbox route answers with. */
|
|
73
|
+
export declare function refusal(status: number, reason: OutreachApiRefusalReason, error: string): Response;
|
|
74
|
+
export declare function readOrgId(value: unknown): string | null;
|
|
75
|
+
export declare function outreachMemberGate(request: Request, rawOrgId: unknown, deps: OutreachGateDeps): Promise<Response | OutreachGateContext>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { checkEntitlement } from "@aglyn/aglyn/server";
|
|
17
|
+
import { isEmailVerified, isImpersonationSession } from "@aglyn/tenant-data-admin/server/firebase-admin";
|
|
18
|
+
import { invalidIdTokenResponse } from "@aglyn/tenant-data-admin/server/id-token-refusal";
|
|
19
|
+
import { OUTREACH_USE_PERMISSION } from "../constants/bundle-common.js";
|
|
20
|
+
/** A refusal in the one shape every mailbox route answers with. */ export function refusal(status, reason, error) {
|
|
21
|
+
return Response.json({
|
|
22
|
+
error,
|
|
23
|
+
reason
|
|
24
|
+
}, {
|
|
25
|
+
status,
|
|
26
|
+
headers: {
|
|
27
|
+
'Cache-Control': 'no-store'
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** An organization id as a request may name one: a plain document id. */ const ORG_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
32
|
+
export function readOrgId(value) {
|
|
33
|
+
const orgId = typeof value === 'string' ? value.trim() : '';
|
|
34
|
+
return ORG_ID.test(orgId) ? orgId : null;
|
|
35
|
+
}
|
|
36
|
+
function bearer(request) {
|
|
37
|
+
var _request_headers_get;
|
|
38
|
+
const authorization = (_request_headers_get = request.headers.get('authorization')) != null ? _request_headers_get : '';
|
|
39
|
+
return authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : null;
|
|
40
|
+
}
|
|
41
|
+
export async function outreachMemberGate(request, rawOrgId, deps) {
|
|
42
|
+
var _ref;
|
|
43
|
+
const token = bearer(request);
|
|
44
|
+
if (!token) return refusal(401, 'unauthenticated', 'Sign in to manage your connected mailboxes.');
|
|
45
|
+
let decoded;
|
|
46
|
+
try {
|
|
47
|
+
decoded = await deps.verifyIdToken(token);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
// A refused credential is the caller's; an Auth outage is ours and
|
|
50
|
+
// propagates, so it answers 500 instead of "sign in again".
|
|
51
|
+
if (invalidIdTokenResponse(error)) {
|
|
52
|
+
return refusal(401, 'unauthenticated', 'Your sign-in could not be confirmed. Sign in again.');
|
|
53
|
+
}
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
if (!isEmailVerified(decoded) && !isImpersonationSession(decoded)) {
|
|
57
|
+
return refusal(403, 'email-unverified', 'Verify your email address before connecting a mailbox.');
|
|
58
|
+
}
|
|
59
|
+
const orgId = readOrgId(rawOrgId);
|
|
60
|
+
if (!orgId) return refusal(400, 'org-required', 'Open an organization before managing its mailboxes.');
|
|
61
|
+
const membership = await deps.resolveOrgPermissions(decoded.uid, {
|
|
62
|
+
orgId
|
|
63
|
+
});
|
|
64
|
+
// A targeted org the account is not on answers with that org's id and no
|
|
65
|
+
// role — as does a lookup that failed closed — so the role is the test.
|
|
66
|
+
if (!membership.role || membership.orgId !== orgId) {
|
|
67
|
+
return refusal(403, 'not-a-member', 'You are not a member of that organization.');
|
|
68
|
+
}
|
|
69
|
+
if (!membership.orgWide) {
|
|
70
|
+
return refusal(403, 'not-org-wide', 'Sequences covers the whole organization, and your access is to particular sites.');
|
|
71
|
+
}
|
|
72
|
+
if (membership.permissions[OUTREACH_USE_PERMISSION] !== true) {
|
|
73
|
+
return refusal(403, 'permission', 'Your role does not include Use Sequences.');
|
|
74
|
+
}
|
|
75
|
+
const org = (_ref = await deps.readOrg(orgId)) != null ? _ref : {};
|
|
76
|
+
if (!checkEntitlement(org, 'outreach')) {
|
|
77
|
+
return refusal(403, 'entitlement', "Sequences isn't available to this workspace yet.");
|
|
78
|
+
}
|
|
79
|
+
const staff = decoded['staff'] === true;
|
|
80
|
+
const locked = await deps.lockdownRefusal({
|
|
81
|
+
request,
|
|
82
|
+
staff,
|
|
83
|
+
uid: decoded.uid,
|
|
84
|
+
org
|
|
85
|
+
});
|
|
86
|
+
if (locked) return locked;
|
|
87
|
+
return {
|
|
88
|
+
uid: decoded.uid,
|
|
89
|
+
email: typeof decoded.email === 'string' ? decoded.email.trim().toLowerCase() : null,
|
|
90
|
+
name: typeof decoded['name'] === 'string' ? String(decoded['name']) : null,
|
|
91
|
+
staff,
|
|
92
|
+
orgId,
|
|
93
|
+
org,
|
|
94
|
+
isOrgAdmin: membership.isOwner
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
//# sourceMappingURL=mailbox-gate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/mailbox-gate.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { checkEntitlement } from '@aglyn/aglyn/server'\nimport {\n isEmailVerified,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport type { DecodedIdToken } from 'firebase-admin/auth'\nimport { OUTREACH_USE_PERMISSION } from '../constants/bundle-common'\nimport type { OutreachApiRefusalReason } from './mailbox-api'\n\n/**\n * THE GATE EVERY AUTHENTICATED MAILBOX ROUTE CLIMBS (AGL-2978).\n *\n * The console dispatcher has already refused a request while\n * `release_outreach` is off for the organization it names, an unverified\n * account's token, a lockdown with no org in scope, and a caller over the\n * console rate limit. What it cannot know is anything about the member, so\n * each rung below is this plugin's:\n *\n * 401 no bearer token, or one the verifier refused\n * 403 the account's address is unverified (an impersonation session is exempt)\n * 400 the request did not name an organization\n * 403 not a member of it, a site collaborator without org-wide reach, or\n * without `outreach.use`\n * 403 the organization does not hold the `outreach` entitlement\n * 423 lockdown, now with the organization and the account in scope\n *\n * `resolveOrgPermissions` is always given the concrete org id. Called without\n * one it resolves a caller with no org at all as that future org's owner,\n * which is right for a fresh signup and wrong for every question asked here.\n */\n\nexport interface OutreachGateDeps {\n verifyIdToken(idToken: string): Promise<DecodedIdToken>\n resolveOrgPermissions(\n uid: string,\n context: { orgId: string },\n ): Promise<{\n orgId: string | null\n /** `null` when the account is not on the organization's roster. */\n role: string | null\n isOwner: boolean\n orgWide: boolean\n permissions: Partial<Record<string, boolean | undefined>>\n }>\n readOrg(orgId: string): Promise<Record<string, unknown> | null>\n lockdownRefusal(options: {\n request: Request\n staff: boolean\n uid: string\n org: Record<string, unknown>\n }): Promise<Response | null>\n}\n\nexport interface OutreachGateContext {\n uid: string\n /** The account's sign-in address, lower-cased, when the token carries one. */\n email: string | null\n /** The account's name, when the token carries one. */\n name: string | null\n staff: boolean\n orgId: string\n org: Record<string, unknown>\n /** An owner or admin of the organization, who may manage every mailbox. */\n isOrgAdmin: boolean\n}\n\n/** A refusal in the one shape every mailbox route answers with. */\nexport function refusal(status: number, reason: OutreachApiRefusalReason, error: string): Response {\n return Response.json({ error, reason }, { status, headers: { 'Cache-Control': 'no-store' } })\n}\n\n/** An organization id as a request may name one: a plain document id. */\nconst ORG_ID = /^[A-Za-z0-9_-]{1,128}$/\n\nexport function readOrgId(value: unknown): string | null {\n const orgId = typeof value === 'string' ? value.trim() : ''\n return ORG_ID.test(orgId) ? orgId : null\n}\n\nfunction bearer(request: Request): string | null {\n const authorization = request.headers.get('authorization') ?? ''\n return authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : null\n}\n\nexport async function outreachMemberGate(\n request: Request,\n rawOrgId: unknown,\n deps: OutreachGateDeps,\n): Promise<Response | OutreachGateContext> {\n const token = bearer(request)\n if (!token) return refusal(401, 'unauthenticated', 'Sign in to manage your connected mailboxes.')\n let decoded: DecodedIdToken\n try {\n decoded = await deps.verifyIdToken(token)\n } catch (error) {\n // A refused credential is the caller's; an Auth outage is ours and\n // propagates, so it answers 500 instead of \"sign in again\".\n if (invalidIdTokenResponse(error)) {\n return refusal(401, 'unauthenticated', 'Your sign-in could not be confirmed. Sign in again.')\n }\n throw error\n }\n if (!isEmailVerified(decoded) && !isImpersonationSession(decoded)) {\n return refusal(403, 'email-unverified', 'Verify your email address before connecting a mailbox.')\n }\n\n const orgId = readOrgId(rawOrgId)\n if (!orgId) return refusal(400, 'org-required', 'Open an organization before managing its mailboxes.')\n\n const membership = await deps.resolveOrgPermissions(decoded.uid, { orgId })\n // A targeted org the account is not on answers with that org's id and no\n // role — as does a lookup that failed closed — so the role is the test.\n if (!membership.role || membership.orgId !== orgId) {\n return refusal(403, 'not-a-member', 'You are not a member of that organization.')\n }\n if (!membership.orgWide) {\n return refusal(403, 'not-org-wide', 'Sequences covers the whole organization, and your access is to particular sites.')\n }\n if (membership.permissions[OUTREACH_USE_PERMISSION] !== true) {\n return refusal(403, 'permission', 'Your role does not include Use Sequences.')\n }\n\n const org = (await deps.readOrg(orgId)) ?? {}\n if (!checkEntitlement(org, 'outreach')) {\n return refusal(403, 'entitlement', \"Sequences isn't available to this workspace yet.\")\n }\n const staff = decoded['staff'] === true\n const locked = await deps.lockdownRefusal({ request, staff, uid: decoded.uid, org })\n if (locked) return locked\n\n return {\n uid: decoded.uid,\n email: typeof decoded.email === 'string' ? decoded.email.trim().toLowerCase() : null,\n name: typeof decoded['name'] === 'string' ? String(decoded['name']) : null,\n staff,\n orgId,\n org,\n isOrgAdmin: membership.isOwner,\n }\n}\n"],"names":["checkEntitlement","isEmailVerified","isImpersonationSession","invalidIdTokenResponse","OUTREACH_USE_PERMISSION","refusal","status","reason","error","Response","json","headers","ORG_ID","readOrgId","value","orgId","trim","test","bearer","request","authorization","get","startsWith","slice","length","outreachMemberGate","rawOrgId","deps","token","decoded","verifyIdToken","membership","resolveOrgPermissions","uid","role","orgWide","permissions","org","readOrg","staff","locked","lockdownRefusal","email","toLowerCase","name","String","isOrgAdmin","isOwner"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,gBAAgB,QAAQ,sBAAqB;AACtD,SACEC,eAAe,EACfC,sBAAsB,QACjB,iDAAgD;AACvD,SAASC,sBAAsB,QAAQ,mDAAkD;AAEzF,SAASC,uBAAuB,QAAQ,gCAA4B;AA4DpE,iEAAiE,GACjE,OAAO,SAASC,QAAQC,MAAc,EAAEC,MAAgC,EAAEC,KAAa;IACrF,OAAOC,SAASC,IAAI,CAAC;QAAEF;QAAOD;IAAO,GAAG;QAAED;QAAQK,SAAS;YAAE,iBAAiB;QAAW;IAAE;AAC7F;AAEA,uEAAuE,GACvE,MAAMC,SAAS;AAEf,OAAO,SAASC,UAAUC,KAAc;IACtC,MAAMC,QAAQ,OAAOD,UAAU,WAAWA,MAAME,IAAI,KAAK;IACzD,OAAOJ,OAAOK,IAAI,CAACF,SAASA,QAAQ;AACtC;AAEA,SAASG,OAAOC,OAAgB;QACRA;IAAtB,MAAMC,iBAAgBD,uBAAAA,QAAQR,OAAO,CAACU,GAAG,CAAC,4BAApBF,uBAAwC;IAC9D,OAAOC,cAAcE,UAAU,CAAC,aAAaF,cAAcG,KAAK,CAAC,UAAUC,MAAM,IAAI;AACvF;AAEA,OAAO,eAAeC,mBACpBN,OAAgB,EAChBO,QAAiB,EACjBC,IAAsB;QAmCT;IAjCb,MAAMC,QAAQV,OAAOC;IACrB,IAAI,CAACS,OAAO,OAAOvB,QAAQ,KAAK,mBAAmB;IACnD,IAAIwB;IACJ,IAAI;QACFA,UAAU,MAAMF,KAAKG,aAAa,CAACF;IACrC,EAAE,OAAOpB,OAAO;QACd,mEAAmE;QACnE,4DAA4D;QAC5D,IAAIL,uBAAuBK,QAAQ;YACjC,OAAOH,QAAQ,KAAK,mBAAmB;QACzC;QACA,MAAMG;IACR;IACA,IAAI,CAACP,gBAAgB4B,YAAY,CAAC3B,uBAAuB2B,UAAU;QACjE,OAAOxB,QAAQ,KAAK,oBAAoB;IAC1C;IAEA,MAAMU,QAAQF,UAAUa;IACxB,IAAI,CAACX,OAAO,OAAOV,QAAQ,KAAK,gBAAgB;IAEhD,MAAM0B,aAAa,MAAMJ,KAAKK,qBAAqB,CAACH,QAAQI,GAAG,EAAE;QAAElB;IAAM;IACzE,yEAAyE;IACzE,wEAAwE;IACxE,IAAI,CAACgB,WAAWG,IAAI,IAAIH,WAAWhB,KAAK,KAAKA,OAAO;QAClD,OAAOV,QAAQ,KAAK,gBAAgB;IACtC;IACA,IAAI,CAAC0B,WAAWI,OAAO,EAAE;QACvB,OAAO9B,QAAQ,KAAK,gBAAgB;IACtC;IACA,IAAI0B,WAAWK,WAAW,CAAChC,wBAAwB,KAAK,MAAM;QAC5D,OAAOC,QAAQ,KAAK,cAAc;IACpC;IAEA,MAAMgC,OAAO,OAAA,MAAMV,KAAKW,OAAO,CAACvB,kBAAnB,OAA8B,CAAC;IAC5C,IAAI,CAACf,iBAAiBqC,KAAK,aAAa;QACtC,OAAOhC,QAAQ,KAAK,eAAe;IACrC;IACA,MAAMkC,QAAQV,OAAO,CAAC,QAAQ,KAAK;IACnC,MAAMW,SAAS,MAAMb,KAAKc,eAAe,CAAC;QAAEtB;QAASoB;QAAON,KAAKJ,QAAQI,GAAG;QAAEI;IAAI;IAClF,IAAIG,QAAQ,OAAOA;IAEnB,OAAO;QACLP,KAAKJ,QAAQI,GAAG;QAChBS,OAAO,OAAOb,QAAQa,KAAK,KAAK,WAAWb,QAAQa,KAAK,CAAC1B,IAAI,GAAG2B,WAAW,KAAK;QAChFC,MAAM,OAAOf,OAAO,CAAC,OAAO,KAAK,WAAWgB,OAAOhB,OAAO,CAAC,OAAO,IAAI;QACtEU;QACAxB;QACAsB;QACAS,YAAYf,WAAWgB,OAAO;IAChC;AACF"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { TransportDeps } from '../transport/http';
|
|
18
|
+
import type { OutreachMailboxDisconnectResponse } from './mailbox-api';
|
|
19
|
+
import { type OutreachGoogleMailboxCredentials } from './mailbox-credentials';
|
|
20
|
+
import type { OutreachGoogleConfigResult } from './outreach-config';
|
|
21
|
+
/**
|
|
22
|
+
* TELLING GOOGLE A STORED GRANT IS OVER (AGL-2978).
|
|
23
|
+
*
|
|
24
|
+
* A disconnect, a workspace erasure and an account erasure all end a
|
|
25
|
+
* mailbox's grant the same way: open its sealed refresh token with this
|
|
26
|
+
* deployment's key and revoke it at Google — unless another stored
|
|
27
|
+
* credential still uses the same Google account, because Google's
|
|
28
|
+
* revocation ends the whole grant this client holds for the account, and
|
|
29
|
+
* revoking it would silently break that other mailbox.
|
|
30
|
+
*
|
|
31
|
+
* Never throws. A grant that cannot be revoked is still deleted by whoever
|
|
32
|
+
* called this; the outcome says which happened.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* What became of a grant at Google: `revoked`, `already-invalid`,
|
|
36
|
+
* `kept-for-other-mailbox` while another credential still uses the account,
|
|
37
|
+
* or `failed` when Google could not be told — the token would not open, the
|
|
38
|
+
* deployment is not configured, or Google did not answer.
|
|
39
|
+
*/
|
|
40
|
+
export type OutreachGrantRevocation = OutreachMailboxDisconnectResponse['revocation'];
|
|
41
|
+
export interface OutreachRevokeDeps {
|
|
42
|
+
readConfig(): OutreachGoogleConfigResult;
|
|
43
|
+
/** The network and retry policy the revocation call uses. */
|
|
44
|
+
transport: TransportDeps;
|
|
45
|
+
}
|
|
46
|
+
export interface RevokeMailboxGrantOptions {
|
|
47
|
+
/**
|
|
48
|
+
* Credentials of this organization do not count as another use of the
|
|
49
|
+
* account: the workspace erasure is ending all of them.
|
|
50
|
+
*/
|
|
51
|
+
excludeOrgId?: string;
|
|
52
|
+
/**
|
|
53
|
+
* These credentials do not count as another use of the account either:
|
|
54
|
+
* the account erasure is ending every one of the person's grants.
|
|
55
|
+
*/
|
|
56
|
+
excludeMailboxIds?: ReadonlySet<string>;
|
|
57
|
+
}
|
|
58
|
+
/** Revokes one stored grant at Google, unless it is still shared. Never throws. */
|
|
59
|
+
export declare function revokeMailboxGrant(firestore: FirebaseFirestore.Firestore, credential: OutreachGoogleMailboxCredentials, deps: OutreachRevokeDeps, options?: RevokeMailboxGrantOptions): Promise<OutreachGrantRevocation>;
|