@aglyn/plugins-crm 1.0.0-beta.285 → 1.0.0-beta.288
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -14
- package/src/lib/components/contact-associations-card.js +5 -7
- package/src/lib/components/contact-associations-card.js.map +1 -1
- package/src/lib/components/contact-list-columns.js +5 -7
- package/src/lib/components/contact-list-columns.js.map +1 -1
- package/src/lib/components/contact-timeline-card.js +4 -3
- package/src/lib/components/contact-timeline-card.js.map +1 -1
- package/src/lib/components/erase-person-action.d.ts +6 -4
- package/src/lib/components/erase-person-action.js +10 -8
- package/src/lib/components/erase-person-action.js.map +1 -1
- package/src/lib/components/reports/contacts-mix-card.js +4 -6
- package/src/lib/components/reports/contacts-mix-card.js.map +1 -1
- package/src/lib/components/reports/source-conversion-card.js +2 -4
- package/src/lib/components/reports/source-conversion-card.js.map +1 -1
- package/src/lib/components/use-can-erase-people.d.ts +32 -0
- package/src/lib/components/use-can-erase-people.js +56 -0
- package/src/lib/components/use-can-erase-people.js.map +1 -0
- package/src/lib/model/record-facts-disclosed.js +4 -6
- package/src/lib/model/record-facts-disclosed.js.map +1 -1
- package/src/lib/model/record-facts.js +3 -5
- package/src/lib/model/record-facts.js.map +1 -1
- package/src/lib/server/capture-contact.d.ts +5 -5
- package/src/lib/server/capture-contact.js +7 -6
- package/src/lib/server/capture-contact.js.map +1 -1
- package/src/lib/server/erase-person.d.ts +10 -7
- package/src/lib/server/erase-person.js +18 -13
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/org-caller.d.ts +16 -4
- package/src/lib/server/org-caller.js +20 -4
- package/src/lib/server/org-caller.js.map +1 -1
|
@@ -55,13 +55,16 @@ export interface ErasePersonResponse {
|
|
|
55
55
|
*
|
|
56
56
|
* ## Who may
|
|
57
57
|
*
|
|
58
|
-
* A
|
|
59
|
-
* and nobody else.
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
58
|
+
* A member who holds "Privacy requests" (`org.privacy`, AGL-4047) over the
|
|
59
|
+
* whole workspace, and nobody else. The key is on for owners and admins by
|
|
60
|
+
* default — the two roles this asked for before — and a custom role or an
|
|
61
|
+
* override can hand it to another org-wide member or take it from an admin.
|
|
62
|
+
* Not a site editor with the data permission, who can detach a contact from
|
|
63
|
+
* their own site but has no standing to remove the person from every site in
|
|
64
|
+
* the workspace; not a site collaborator, granted the key or not, for the
|
|
65
|
+
* same reason; and not staff acting alone, because the workspace is the
|
|
66
|
+
* controller of this data and the instruction has to come from it. Staff who
|
|
67
|
+
* are also admins of the workspace pass on that, as anyone would.
|
|
65
68
|
*
|
|
66
69
|
* ## What it does, and what it does not
|
|
67
70
|
*
|
|
@@ -14,9 +14,9 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
14
14
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
|
-
*/ import {
|
|
17
|
+
*/ import { CONTACT_ERASURE_REQUESTED_FIELD, normalizeContactEmail, PERSON_ERASURES_COLLECTION, personErasureConfirmationMatches, personErasureId } from "@aglyn/aglyn/server";
|
|
18
18
|
import { personKey } from "@aglyn/aglyn/app-utils/person-key";
|
|
19
|
-
import { firebaseAdmin, getOrgForHost, logHostActivity, logOrgActivity, orgDataCollectionForHost, readLeadForHost, suppressEmailForHostErasure } from "@aglyn/tenant-data-admin";
|
|
19
|
+
import { firebaseAdmin, getOrgForHost, logHostActivity, logOrgActivity, memberHasOrgPermission, orgDataCollectionForHost, readLeadForHost, resolveOrgMembership, suppressEmailForHostErasure } from "@aglyn/tenant-data-admin";
|
|
20
20
|
import { resolveOrgPermissions } from "@aglyn/tenant-runtime/org-permissions";
|
|
21
21
|
import { FieldValue } from "firebase-admin/firestore";
|
|
22
22
|
import { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from "./org-caller.js";
|
|
@@ -28,13 +28,16 @@ import { addAdminAudit } from "@aglyn/tenant-data-admin/server/admin-audit-write
|
|
|
28
28
|
*
|
|
29
29
|
* ## Who may
|
|
30
30
|
*
|
|
31
|
-
* A
|
|
32
|
-
* and nobody else.
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
31
|
+
* A member who holds "Privacy requests" (`org.privacy`, AGL-4047) over the
|
|
32
|
+
* whole workspace, and nobody else. The key is on for owners and admins by
|
|
33
|
+
* default — the two roles this asked for before — and a custom role or an
|
|
34
|
+
* override can hand it to another org-wide member or take it from an admin.
|
|
35
|
+
* Not a site editor with the data permission, who can detach a contact from
|
|
36
|
+
* their own site but has no standing to remove the person from every site in
|
|
37
|
+
* the workspace; not a site collaborator, granted the key or not, for the
|
|
38
|
+
* same reason; and not staff acting alone, because the workspace is the
|
|
39
|
+
* controller of this data and the instruction has to come from it. Staff who
|
|
40
|
+
* are also admins of the workspace pass on that, as anyone would.
|
|
38
41
|
*
|
|
39
42
|
* ## What it does, and what it does not
|
|
40
43
|
*
|
|
@@ -112,13 +115,13 @@ import { addAdminAudit } from "@aglyn/tenant-data-admin/server/admin-audit-write
|
|
|
112
115
|
});
|
|
113
116
|
return;
|
|
114
117
|
}
|
|
115
|
-
const refusal = '
|
|
118
|
+
const refusal = 'Your role does not include "Privacy requests" — ask an organization admin';
|
|
116
119
|
try {
|
|
117
120
|
let orgId;
|
|
118
121
|
let actor;
|
|
119
122
|
if (routeScope.level === 'org') {
|
|
120
123
|
const caller = await authorizeOrgCaller(req, routeScope.orgId, {
|
|
121
|
-
needs: '
|
|
124
|
+
needs: 'org.privacy',
|
|
122
125
|
refusal
|
|
123
126
|
});
|
|
124
127
|
if (caller.ok === false) {
|
|
@@ -133,12 +136,14 @@ import { addAdminAudit } from "@aglyn/tenant-data-admin/server/admin-audit-write
|
|
|
133
136
|
email: caller.email
|
|
134
137
|
};
|
|
135
138
|
} else {
|
|
136
|
-
var _decoded_email;
|
|
139
|
+
var _ref, _decoded_email;
|
|
140
|
+
var _this;
|
|
137
141
|
const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
|
|
138
142
|
const membership = await resolveOrgPermissions(decoded.uid, {
|
|
139
143
|
hostId
|
|
140
144
|
});
|
|
141
|
-
|
|
145
|
+
const member = membership.orgId ? (_ref = (_this = await resolveOrgMembership(decoded.uid, membership.orgId)) == null ? void 0 : _this.member) != null ? _ref : null : null;
|
|
146
|
+
if (!membership.orgWide || !membership.orgId || !await memberHasOrgPermission(membership.orgId, member, 'org.privacy')) {
|
|
142
147
|
res.status(403).json({
|
|
143
148
|
error: refusal
|
|
144
149
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/erase-person.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 canManageOrg,\n CONTACT_ERASURE_REQUESTED_FIELD,\n normalizeContactEmail,\n PERSON_ERASURES_COLLECTION,\n personErasureConfirmationMatches,\n personErasureId,\n type PersonErasureRequest,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport {\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n logOrgActivity,\n orgDataCollectionForHost,\n readLeadForHost,\n suppressEmailForHostErasure,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from './org-caller'\nimport { addAdminAudit } from '@aglyn/tenant-data-admin/server/admin-audit-write'\n\n/** The route key, as `registerCrmConsoleApi` registers it. */\nexport const CRM_ERASE_PERSON_ROUTE = 'crm/erase-person'\n\nexport interface ErasePersonRequestBody {\n /**\n * The site the request is filed from. At the organization level\n * (AGL-2634) the record's own site, or absent for a contact no site has\n * captured; a LEAD always names one, because a lead lives under its site.\n */\n hostId?: string\n /** The organization, at the organization level (AGL-2634). */\n orgId?: string\n /**\n * At most one of these names the record the request is filed from. Under\n * a site exactly one does; at the organization level neither may, which\n * files the request by `email` alone (AGL-2839).\n */\n contactId?: string\n leadId?: string\n /**\n * The address as the admin typed it — the confirmation of a record's\n * address, or, filed by address alone, the person the request is about.\n */\n email: string\n /** Filed by address alone: the address typed a second time. */\n confirmEmail?: string\n}\n\nexport interface ErasePersonResponse {\n ok: true\n requestId: string\n /** When the request entered the queue. */\n pendingSinceMs: number\n /** True when a request for this person was already waiting; nothing was re-filed. */\n alreadyPending: boolean\n}\n\n/**\n * `POST /api/crm/erase-person` — file a privacy erasure for one person\n * (AGL-2623).\n *\n * ## Who may\n *\n * A workspace admin or owner — `canManageOrg` on the caller's org role —\n * and nobody else. Not a site editor with the data permission, who can\n * detach a contact from their own site but has no standing to remove the\n * person from every site in the workspace; and not staff acting alone,\n * because the workspace is the controller of this data and the instruction\n * has to come from it. Staff who are also admins of the workspace pass on\n * that role, as anyone would.\n *\n * ## What it does, and what it does not\n *\n * It files the request and closes the doors; it deletes nothing. The daily\n * erasure job executes the sweep, so that a workspace's person erasures and\n * its own erasure run through one job, one audit trail and one queue a\n * staff member can watch. What happens here, in order:\n *\n * 1. The record named by the body is read and its address is what the\n * request is about — the body's address must match it, typed, so an\n * admin who opened the wrong record is stopped by the confirmation\n * rather than by luck.\n * 2. The request document is written, or found already pending, in\n * which case nothing is re-filed and the caller is told so.\n * 3. Every site of the workspace gets its suppression row NOW, not when\n * the job runs: from this moment a capture cannot rebuild the person.\n * 4. The contact document and each site's lead are stamped with the\n * request time, so their pages can say \"erasure pending\" off the\n * document they already read.\n * 5. The site's activity feed and the platform audit log each get a row\n * that names the record by id and the person by hash, never by\n * address.\n *\n * ## The organization variant (AGL-2634)\n *\n * `{ orgId, hostId?, contactId | leadId, email }` from the org-level hub:\n * the same workspace admin, authorized by the org rather than through a\n * site they name, so a contact no site captured can be erased from the\n * page that lists it. The sweep is what it always was — every site of the\n * org, found by `orgId` — and the row goes to the org's feed. A lead still\n * names its site, because that is where a lead lives.\n *\n * ## By address alone (AGL-2839)\n *\n * `{ orgId, email, confirmEmail }` with no record, from the workspace's\n * Privacy settings: the same workspace admin, for any person the workspace\n * may hold — including one no page shows, since the CRM that lists records\n * is included from Starter and a Free workspace has none of it. Nothing is\n * read to file it: the sweep is keyed by the address, the markers are found\n * by it, and the typed address must match its second typing, which is the\n * confirmation a record's address gives the other variants. No plan is\n * asked on any variant: erasure is an obligation, not a CRM feature.\n */\nexport const crmErasePersonHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n const body: Partial<ErasePersonRequestBody> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const routeScope = readCrmRouteScope(body as Record<string, unknown>)\n const contactId = String(body.contactId ?? '').trim()\n const leadId = String(body.leadId ?? '').trim()\n const byAddress = routeScope?.level === 'org' && !contactId && !leadId\n if (!routeScope || (!byAddress && !contactId && !leadId) || (contactId && leadId)) {\n res.status(400).json({ error: 'Name the site and exactly one contact or lead' })\n return\n }\n const { hostId } = routeScope\n if (leadId && !hostId) {\n res.status(400).json({ error: 'Name the site the lead lives under' })\n return\n }\n const refusal = 'Only a workspace admin can erase a person from the workspace'\n\n try {\n let orgId: string\n let actor: { uid: string; email: string | null }\n if (routeScope.level === 'org') {\n const caller = await authorizeOrgCaller(req, routeScope.orgId, {\n needs: 'manage-org',\n refusal,\n })\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n orgId = caller.orgId\n actor = { uid: caller.uid, email: caller.email }\n } else {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (!canManageOrg(membership.role)) {\n res.status(403).json({ error: refusal })\n return\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved || resolved.orgId !== membership.orgId) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n orgId = resolved.orgId\n actor = { uid: decoded.uid, email: decoded.email ?? null }\n }\n const firestore = firebaseAdmin.app().firestore()\n\n let recordEmail: string | null = null\n if (byAddress) {\n recordEmail = normalizeContactEmail(body.email)\n } else if (contactId) {\n const contactsRef =\n routeScope.level === 'org'\n ? firestore.collection('orgs').doc(orgId).collection('contacts')\n : await orgDataCollectionForHost(hostId, 'contacts')\n const contact = await contactsRef.doc(contactId).get()\n if (!contact.exists) {\n res.status(404).json({ error: 'Unknown contact' })\n return\n }\n recordEmail = normalizeContactEmail(contact.get('email'))\n } else {\n const lead = await readLeadForHost(hostId, leadId)\n if (!lead) {\n res.status(404).json({ error: 'Unknown lead' })\n return\n }\n recordEmail = normalizeContactEmail(lead.get('email'))\n }\n const key = recordEmail ? personKey(recordEmail) : null\n if (!recordEmail || !key) {\n res.status(byAddress ? 400 : 422).json({\n error: byAddress\n ? 'Enter the email address of the person to erase'\n : 'This record has no usable email address to erase by',\n })\n return\n }\n if (byAddress && !personErasureConfirmationMatches(body.confirmEmail, recordEmail)) {\n res.status(400).json({\n error: 'Type the email address a second time, exactly, to confirm the erasure',\n })\n return\n }\n if (!byAddress && !personErasureConfirmationMatches(body.email, recordEmail)) {\n res.status(400).json({\n error: 'Type the record’s email address exactly to confirm the erasure',\n })\n return\n }\n\n const requestId = personErasureId(orgId, key)\n const requestRef = firestore.collection(PERSON_ERASURES_COLLECTION).doc(requestId)\n const existing = await requestRef.get()\n if (existing.exists && existing.get('status') === 'pending') {\n const answer: ErasePersonResponse = {\n ok: true,\n requestId,\n pendingSinceMs: Number(existing.get('pendingSinceMs') ?? 0),\n alreadyPending: true,\n }\n res.status(200).json(answer)\n return\n }\n\n const now = Date.now()\n const request: PersonErasureRequest = {\n orgId,\n personKey: key,\n status: 'pending',\n email: recordEmail,\n requestedAtMs: now,\n requestedByUid: actor.uid,\n ...(hostId ? { hostId } : {}),\n ...(contactId ? { contactId } : {}),\n ...(leadId ? { leadId } : {}),\n pendingSinceMs: now,\n }\n await requestRef.set(\n {\n ...request,\n // A request re-filed after a failed or completed run starts clean.\n erasedAtMs: FieldValue.delete(),\n result: FieldValue.delete(),\n failedAtMs: FieldValue.delete(),\n lastError: FieldValue.delete(),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n\n const hostIds = await orgHostIds(firestore, orgId)\n /*\n * Suppression stays PER SITE — it is that site's sending list — while the\n * lead marker below is written once, because the lead is one org row now\n * (AGL-3275) rather than one row per site.\n */\n for (const siteId of hostIds) {\n await suppressEmailForHostErasure({ hostId: siteId, email: recordEmail }).catch(\n (error: unknown) => {\n console.error('[crm] erase-person suppression write failed', siteId, error)\n },\n )\n }\n /*\n * THE ORG ROW. One document for the person, since AGL-3276 emptied the\n * host path and AGL-3277 removed it — the per-site sweep this used to do\n * beside it had nothing left to find.\n */\n const eraseLeadAt = async (\n ref: FirebaseFirestore.DocumentReference,\n where: string,\n ) => {\n await ref\n .get()\n .then((snapshot) =>\n snapshot.exists\n ? ref.update({ [CONTACT_ERASURE_REQUESTED_FIELD]: now })\n : undefined,\n )\n .catch((error: unknown) => {\n console.error('[crm] erase-person lead marker failed', where, error)\n })\n }\n await eraseLeadAt(firestore.collection('orgs').doc(orgId).collection('leads').doc(key), orgId)\n try {\n const contacts = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('contacts')\n .where('email', '==', recordEmail)\n .get()\n for (const contact of contacts.docs) {\n await contact.ref.update({ [CONTACT_ERASURE_REQUESTED_FIELD]: now })\n }\n } catch (error) {\n console.error('[crm] erase-person contact marker failed', orgId, error)\n }\n\n // The feed the act was performed in: the site's under a site, the\n // org's at the organization level.\n const target = { type: contactId ? 'contact' : 'lead', id: contactId || leadId } as const\n if (routeScope.level === 'org') {\n // Filed by address, the line names no record — and never the address.\n await logOrgActivity(\n orgId,\n actor,\n 'Requested privacy erasure',\n byAddress ? { type: 'org' } : target,\n ).catch(() => undefined)\n } else {\n await logHostActivity(hostId, actor, 'Requested privacy erasure', target).catch(\n () => undefined,\n )\n }\n await addAdminAudit(firestore, {\n actorUid: actor.uid,\n action: 'person.erasure-requested',\n target: `orgs/${orgId}/people/${key}`,\n before: null,\n after: {\n hostId: hostId || null,\n hosts: hostIds.length,\n from: byAddress ? 'address' : contactId ? 'contact' : 'lead',\n },\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n\n const answer: ErasePersonResponse = {\n ok: true,\n requestId,\n pendingSinceMs: now,\n alreadyPending: false,\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] erase-person failed', routeScope, error)\n res.status(500).json({ error: 'The erasure could not be filed' })\n }\n}\n"],"names":["canManageOrg","CONTACT_ERASURE_REQUESTED_FIELD","normalizeContactEmail","PERSON_ERASURES_COLLECTION","personErasureConfirmationMatches","personErasureId","personKey","firebaseAdmin","getOrgForHost","logHostActivity","logOrgActivity","orgDataCollectionForHost","readLeadForHost","suppressEmailForHostErasure","resolveOrgPermissions","FieldValue","authorizeOrgCaller","orgHostIds","readCrmRouteScope","addAdminAudit","CRM_ERASE_PERSON_ROUTE","crmErasePersonHandler","req","res","body","method","setHeader","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","routeScope","contactId","trim","leadId","byAddress","level","hostId","refusal","orgId","actor","caller","needs","ok","uid","email","decoded","app","auth","verifyIdToken","membership","role","resolved","firestore","recordEmail","contactsRef","collection","doc","contact","get","exists","lead","key","confirmEmail","requestId","requestRef","existing","answer","pendingSinceMs","Number","alreadyPending","now","Date","request","requestedAtMs","requestedByUid","set","erasedAtMs","delete","result","failedAtMs","lastError","updatedAt","serverTimestamp","merge","hostIds","siteId","catch","console","eraseLeadAt","ref","where","then","snapshot","update","contacts","docs","target","type","id","actorUid","action","before","after","hosts","from","at"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/erase-person.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 CONTACT_ERASURE_REQUESTED_FIELD,\n normalizeContactEmail,\n PERSON_ERASURES_COLLECTION,\n personErasureConfirmationMatches,\n personErasureId,\n type PersonErasureRequest,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport {\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n logOrgActivity,\n memberHasOrgPermission,\n orgDataCollectionForHost,\n readLeadForHost,\n resolveOrgMembership,\n suppressEmailForHostErasure,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from './org-caller'\nimport { addAdminAudit } from '@aglyn/tenant-data-admin/server/admin-audit-write'\n\n/** The route key, as `registerCrmConsoleApi` registers it. */\nexport const CRM_ERASE_PERSON_ROUTE = 'crm/erase-person'\n\nexport interface ErasePersonRequestBody {\n /**\n * The site the request is filed from. At the organization level\n * (AGL-2634) the record's own site, or absent for a contact no site has\n * captured; a LEAD always names one, because a lead lives under its site.\n */\n hostId?: string\n /** The organization, at the organization level (AGL-2634). */\n orgId?: string\n /**\n * At most one of these names the record the request is filed from. Under\n * a site exactly one does; at the organization level neither may, which\n * files the request by `email` alone (AGL-2839).\n */\n contactId?: string\n leadId?: string\n /**\n * The address as the admin typed it — the confirmation of a record's\n * address, or, filed by address alone, the person the request is about.\n */\n email: string\n /** Filed by address alone: the address typed a second time. */\n confirmEmail?: string\n}\n\nexport interface ErasePersonResponse {\n ok: true\n requestId: string\n /** When the request entered the queue. */\n pendingSinceMs: number\n /** True when a request for this person was already waiting; nothing was re-filed. */\n alreadyPending: boolean\n}\n\n/**\n * `POST /api/crm/erase-person` — file a privacy erasure for one person\n * (AGL-2623).\n *\n * ## Who may\n *\n * A member who holds \"Privacy requests\" (`org.privacy`, AGL-4047) over the\n * whole workspace, and nobody else. The key is on for owners and admins by\n * default — the two roles this asked for before — and a custom role or an\n * override can hand it to another org-wide member or take it from an admin.\n * Not a site editor with the data permission, who can detach a contact from\n * their own site but has no standing to remove the person from every site in\n * the workspace; not a site collaborator, granted the key or not, for the\n * same reason; and not staff acting alone, because the workspace is the\n * controller of this data and the instruction has to come from it. Staff who\n * are also admins of the workspace pass on that, as anyone would.\n *\n * ## What it does, and what it does not\n *\n * It files the request and closes the doors; it deletes nothing. The daily\n * erasure job executes the sweep, so that a workspace's person erasures and\n * its own erasure run through one job, one audit trail and one queue a\n * staff member can watch. What happens here, in order:\n *\n * 1. The record named by the body is read and its address is what the\n * request is about — the body's address must match it, typed, so an\n * admin who opened the wrong record is stopped by the confirmation\n * rather than by luck.\n * 2. The request document is written, or found already pending, in\n * which case nothing is re-filed and the caller is told so.\n * 3. Every site of the workspace gets its suppression row NOW, not when\n * the job runs: from this moment a capture cannot rebuild the person.\n * 4. The contact document and each site's lead are stamped with the\n * request time, so their pages can say \"erasure pending\" off the\n * document they already read.\n * 5. The site's activity feed and the platform audit log each get a row\n * that names the record by id and the person by hash, never by\n * address.\n *\n * ## The organization variant (AGL-2634)\n *\n * `{ orgId, hostId?, contactId | leadId, email }` from the org-level hub:\n * the same workspace admin, authorized by the org rather than through a\n * site they name, so a contact no site captured can be erased from the\n * page that lists it. The sweep is what it always was — every site of the\n * org, found by `orgId` — and the row goes to the org's feed. A lead still\n * names its site, because that is where a lead lives.\n *\n * ## By address alone (AGL-2839)\n *\n * `{ orgId, email, confirmEmail }` with no record, from the workspace's\n * Privacy settings: the same workspace admin, for any person the workspace\n * may hold — including one no page shows, since the CRM that lists records\n * is included from Starter and a Free workspace has none of it. Nothing is\n * read to file it: the sweep is keyed by the address, the markers are found\n * by it, and the typed address must match its second typing, which is the\n * confirmation a record's address gives the other variants. No plan is\n * asked on any variant: erasure is an obligation, not a CRM feature.\n */\nexport const crmErasePersonHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n const body: Partial<ErasePersonRequestBody> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const routeScope = readCrmRouteScope(body as Record<string, unknown>)\n const contactId = String(body.contactId ?? '').trim()\n const leadId = String(body.leadId ?? '').trim()\n const byAddress = routeScope?.level === 'org' && !contactId && !leadId\n if (!routeScope || (!byAddress && !contactId && !leadId) || (contactId && leadId)) {\n res.status(400).json({ error: 'Name the site and exactly one contact or lead' })\n return\n }\n const { hostId } = routeScope\n if (leadId && !hostId) {\n res.status(400).json({ error: 'Name the site the lead lives under' })\n return\n }\n const refusal = 'Your role does not include \"Privacy requests\" — ask an organization admin'\n\n try {\n let orgId: string\n let actor: { uid: string; email: string | null }\n if (routeScope.level === 'org') {\n const caller = await authorizeOrgCaller(req, routeScope.orgId, {\n needs: 'org.privacy',\n refusal,\n })\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n orgId = caller.orgId\n actor = { uid: caller.uid, email: caller.email }\n } else {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n const member = membership.orgId\n ? ((await resolveOrgMembership(decoded.uid, membership.orgId))?.member ?? null)\n : null\n if (\n !membership.orgWide ||\n !membership.orgId ||\n !(await memberHasOrgPermission(membership.orgId, member, 'org.privacy'))\n ) {\n res.status(403).json({ error: refusal })\n return\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved || resolved.orgId !== membership.orgId) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n orgId = resolved.orgId\n actor = { uid: decoded.uid, email: decoded.email ?? null }\n }\n const firestore = firebaseAdmin.app().firestore()\n\n let recordEmail: string | null = null\n if (byAddress) {\n recordEmail = normalizeContactEmail(body.email)\n } else if (contactId) {\n const contactsRef =\n routeScope.level === 'org'\n ? firestore.collection('orgs').doc(orgId).collection('contacts')\n : await orgDataCollectionForHost(hostId, 'contacts')\n const contact = await contactsRef.doc(contactId).get()\n if (!contact.exists) {\n res.status(404).json({ error: 'Unknown contact' })\n return\n }\n recordEmail = normalizeContactEmail(contact.get('email'))\n } else {\n const lead = await readLeadForHost(hostId, leadId)\n if (!lead) {\n res.status(404).json({ error: 'Unknown lead' })\n return\n }\n recordEmail = normalizeContactEmail(lead.get('email'))\n }\n const key = recordEmail ? personKey(recordEmail) : null\n if (!recordEmail || !key) {\n res.status(byAddress ? 400 : 422).json({\n error: byAddress\n ? 'Enter the email address of the person to erase'\n : 'This record has no usable email address to erase by',\n })\n return\n }\n if (byAddress && !personErasureConfirmationMatches(body.confirmEmail, recordEmail)) {\n res.status(400).json({\n error: 'Type the email address a second time, exactly, to confirm the erasure',\n })\n return\n }\n if (!byAddress && !personErasureConfirmationMatches(body.email, recordEmail)) {\n res.status(400).json({\n error: 'Type the record’s email address exactly to confirm the erasure',\n })\n return\n }\n\n const requestId = personErasureId(orgId, key)\n const requestRef = firestore.collection(PERSON_ERASURES_COLLECTION).doc(requestId)\n const existing = await requestRef.get()\n if (existing.exists && existing.get('status') === 'pending') {\n const answer: ErasePersonResponse = {\n ok: true,\n requestId,\n pendingSinceMs: Number(existing.get('pendingSinceMs') ?? 0),\n alreadyPending: true,\n }\n res.status(200).json(answer)\n return\n }\n\n const now = Date.now()\n const request: PersonErasureRequest = {\n orgId,\n personKey: key,\n status: 'pending',\n email: recordEmail,\n requestedAtMs: now,\n requestedByUid: actor.uid,\n ...(hostId ? { hostId } : {}),\n ...(contactId ? { contactId } : {}),\n ...(leadId ? { leadId } : {}),\n pendingSinceMs: now,\n }\n await requestRef.set(\n {\n ...request,\n // A request re-filed after a failed or completed run starts clean.\n erasedAtMs: FieldValue.delete(),\n result: FieldValue.delete(),\n failedAtMs: FieldValue.delete(),\n lastError: FieldValue.delete(),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n\n const hostIds = await orgHostIds(firestore, orgId)\n /*\n * Suppression stays PER SITE — it is that site's sending list — while the\n * lead marker below is written once, because the lead is one org row now\n * (AGL-3275) rather than one row per site.\n */\n for (const siteId of hostIds) {\n await suppressEmailForHostErasure({ hostId: siteId, email: recordEmail }).catch(\n (error: unknown) => {\n console.error('[crm] erase-person suppression write failed', siteId, error)\n },\n )\n }\n /*\n * THE ORG ROW. One document for the person, since AGL-3276 emptied the\n * host path and AGL-3277 removed it — the per-site sweep this used to do\n * beside it had nothing left to find.\n */\n const eraseLeadAt = async (\n ref: FirebaseFirestore.DocumentReference,\n where: string,\n ) => {\n await ref\n .get()\n .then((snapshot) =>\n snapshot.exists\n ? ref.update({ [CONTACT_ERASURE_REQUESTED_FIELD]: now })\n : undefined,\n )\n .catch((error: unknown) => {\n console.error('[crm] erase-person lead marker failed', where, error)\n })\n }\n await eraseLeadAt(firestore.collection('orgs').doc(orgId).collection('leads').doc(key), orgId)\n try {\n const contacts = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('contacts')\n .where('email', '==', recordEmail)\n .get()\n for (const contact of contacts.docs) {\n await contact.ref.update({ [CONTACT_ERASURE_REQUESTED_FIELD]: now })\n }\n } catch (error) {\n console.error('[crm] erase-person contact marker failed', orgId, error)\n }\n\n // The feed the act was performed in: the site's under a site, the\n // org's at the organization level.\n const target = { type: contactId ? 'contact' : 'lead', id: contactId || leadId } as const\n if (routeScope.level === 'org') {\n // Filed by address, the line names no record — and never the address.\n await logOrgActivity(\n orgId,\n actor,\n 'Requested privacy erasure',\n byAddress ? { type: 'org' } : target,\n ).catch(() => undefined)\n } else {\n await logHostActivity(hostId, actor, 'Requested privacy erasure', target).catch(\n () => undefined,\n )\n }\n await addAdminAudit(firestore, {\n actorUid: actor.uid,\n action: 'person.erasure-requested',\n target: `orgs/${orgId}/people/${key}`,\n before: null,\n after: {\n hostId: hostId || null,\n hosts: hostIds.length,\n from: byAddress ? 'address' : contactId ? 'contact' : 'lead',\n },\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n\n const answer: ErasePersonResponse = {\n ok: true,\n requestId,\n pendingSinceMs: now,\n alreadyPending: false,\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] erase-person failed', routeScope, error)\n res.status(500).json({ error: 'The erasure could not be filed' })\n }\n}\n"],"names":["CONTACT_ERASURE_REQUESTED_FIELD","normalizeContactEmail","PERSON_ERASURES_COLLECTION","personErasureConfirmationMatches","personErasureId","personKey","firebaseAdmin","getOrgForHost","logHostActivity","logOrgActivity","memberHasOrgPermission","orgDataCollectionForHost","readLeadForHost","resolveOrgMembership","suppressEmailForHostErasure","resolveOrgPermissions","FieldValue","authorizeOrgCaller","orgHostIds","readCrmRouteScope","addAdminAudit","CRM_ERASE_PERSON_ROUTE","crmErasePersonHandler","req","res","body","method","setHeader","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","routeScope","contactId","trim","leadId","byAddress","level","hostId","refusal","orgId","actor","caller","needs","ok","uid","email","decoded","app","auth","verifyIdToken","membership","member","orgWide","resolved","firestore","recordEmail","contactsRef","collection","doc","contact","get","exists","lead","key","confirmEmail","requestId","requestRef","existing","answer","pendingSinceMs","Number","alreadyPending","now","Date","request","requestedAtMs","requestedByUid","set","erasedAtMs","delete","result","failedAtMs","lastError","updatedAt","serverTimestamp","merge","hostIds","siteId","catch","console","eraseLeadAt","ref","where","then","snapshot","update","contacts","docs","target","type","id","actorUid","action","before","after","hosts","from","at"],"mappings":"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"}
|
|
@@ -86,16 +86,28 @@ export type OrgCallerRequirement =
|
|
|
86
86
|
/** `data.manage`, org-wide — the key the CRM is gated on. Staff pass. */
|
|
87
87
|
'data.manage'
|
|
88
88
|
/** A workspace owner or admin, org-wide. Staff acting alone do NOT pass. */
|
|
89
|
-
| 'manage-org'
|
|
89
|
+
| 'manage-org'
|
|
90
|
+
/**
|
|
91
|
+
* "Privacy requests" (`org.privacy`, AGL-4047), org-wide — who may file a
|
|
92
|
+
* person's erasure. On for owners and admins by default. Staff acting
|
|
93
|
+
* alone do NOT pass.
|
|
94
|
+
*/
|
|
95
|
+
| 'org.privacy';
|
|
96
|
+
/**
|
|
97
|
+
* Whether `uid` holds a catalog key in `orgId` as the catalog resolves it —
|
|
98
|
+
* their role, a custom role and per-member overrides — asked of
|
|
99
|
+
* `memberHasOrgPermission`. A lookup that fails answers `false`.
|
|
100
|
+
*/
|
|
101
|
+
export declare function holdsOrgPermission(orgId: string | null | undefined, uid: string, permission: 'org.privacy'): Promise<boolean>;
|
|
90
102
|
/**
|
|
91
103
|
* A verified session, org-wide reach in `orgId`, and the requirement — in
|
|
92
104
|
* that order, the cheapest refusal first — then the org document, which
|
|
93
105
|
* every org variant needs for the plan and the consent groups.
|
|
94
106
|
*
|
|
95
107
|
* Staff are admitted to the `data.manage` requirement as every CRM route
|
|
96
|
-
* admits them, and refused the `manage-org`
|
|
97
|
-
* refuses them: the workspace is the controller of its
|
|
98
|
-
* the instruction to erase has to come from it.
|
|
108
|
+
* admits them, and refused the `manage-org` and `org.privacy` ones as the
|
|
109
|
+
* erasure route refuses them: the workspace is the controller of its
|
|
110
|
+
* people's data and the instruction to erase has to come from it.
|
|
99
111
|
*/
|
|
100
112
|
export declare function authorizeOrgCaller(req: PluginApiRequest, orgId: string, options: {
|
|
101
113
|
needs: OrgCallerRequirement;
|
|
@@ -77,15 +77,31 @@ export function readCrmRouteScope(body) {
|
|
|
77
77
|
return false;
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Whether `uid` holds a catalog key in `orgId` as the catalog resolves it —
|
|
82
|
+
* their role, a custom role and per-member overrides — asked of
|
|
83
|
+
* `memberHasOrgPermission`. A lookup that fails answers `false`.
|
|
84
|
+
*/ export async function holdsOrgPermission(orgId, uid, permission) {
|
|
85
|
+
if (!orgId) return false;
|
|
86
|
+
try {
|
|
87
|
+
var _ref;
|
|
88
|
+
const membership = await resolveOrgMembership(uid, orgId);
|
|
89
|
+
const member = (_ref = membership == null ? void 0 : membership.member) != null ? _ref : null;
|
|
90
|
+
if (!member) return false;
|
|
91
|
+
return await memberHasOrgPermission(orgId, member, permission) === true;
|
|
92
|
+
} catch (unused) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
80
96
|
/**
|
|
81
97
|
* A verified session, org-wide reach in `orgId`, and the requirement — in
|
|
82
98
|
* that order, the cheapest refusal first — then the org document, which
|
|
83
99
|
* every org variant needs for the plan and the consent groups.
|
|
84
100
|
*
|
|
85
101
|
* Staff are admitted to the `data.manage` requirement as every CRM route
|
|
86
|
-
* admits them, and refused the `manage-org`
|
|
87
|
-
* refuses them: the workspace is the controller of its
|
|
88
|
-
* the instruction to erase has to come from it.
|
|
102
|
+
* admits them, and refused the `manage-org` and `org.privacy` ones as the
|
|
103
|
+
* erasure route refuses them: the workspace is the controller of its
|
|
104
|
+
* people's data and the instruction to erase has to come from it.
|
|
89
105
|
*/ export async function authorizeOrgCaller(req, orgId, options) {
|
|
90
106
|
var _req_headers_authorization, _ref, _decoded_email, _decoded_name;
|
|
91
107
|
const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
|
|
@@ -119,7 +135,7 @@ export function readCrmRouteScope(body) {
|
|
|
119
135
|
orgId
|
|
120
136
|
}).catch(()=>null);
|
|
121
137
|
const orgWide = Boolean(membership == null ? void 0 : membership.orgWide) && (membership == null ? void 0 : membership.orgId) === orgId;
|
|
122
|
-
const admitted = options.needs === 'data.manage' ? staff || orgWide && await holdsDataManage(orgId, decoded.uid) : orgWide && canManageOrg((_ref = membership == null ? void 0 : membership.role) != null ? _ref : null);
|
|
138
|
+
const admitted = options.needs === 'data.manage' ? staff || orgWide && await holdsDataManage(orgId, decoded.uid) : options.needs === 'org.privacy' ? orgWide && await holdsOrgPermission(orgId, decoded.uid, 'org.privacy') : orgWide && canManageOrg((_ref = membership == null ? void 0 : membership.role) != null ? _ref : null);
|
|
123
139
|
if (!admitted) return {
|
|
124
140
|
ok: false,
|
|
125
141
|
status: 403,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/org-caller.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHO IS CALLING AT THE ORGANIZATION LEVEL (AGL-2634).\n *\n * The CRM's host-routed actions — a stage move, a merge, an erasure, an\n * email — were written for the site hub: each resolves the org FROM the\n * site, checks the caller's role ON that site, and logs into that site's\n * feed. The organization-level hub (AGL-2630) mounts the same surfaces with\n * no site, and for a while every one of those actions ran \"as the record's\n * own capturing site\", which left a record nobody's site had captured with\n * nothing to run as. The org variant of each route authorizes HERE instead:\n * by the org the body names, for an org-wide member, with the permission\n * the whole CRM is gated on — the same three facts the org hub's own page\n * gate checks (`resolveOrgCrmAccess`), so what the console offers and what\n * the server admits cannot drift.\n *\n * A ROLE IS NOT A REACH. A site collaborator is a real member document\n * carrying `role: 'editor'`, and that role holds `data.manage` — so a gate\n * that read the permission alone would admit a contractor on one microsite\n * to act over every site in the agency. `orgWide` is checked first and\n * separately, and no permission grant restores it; this is the boundary the\n * host variants get for free from the site role, spelled out for the variant\n * that has no site.\n */\n\nimport { type AglynOrganization, canManageOrg, type PluginApiRequest } from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n getOrgDoc,\n memberHasOrgPermission,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\n\n/**\n * Which level a CRM route runs at, read off its body once.\n *\n * `orgId` present is the ORGANIZATION variant, whatever else the body\n * carries: the org-level console always names its org, and a `hostId`\n * beside it is the record's own site or the site an email leaves from —\n * never what the caller is authorized against. `hostId` alone is the site\n * variant every route has always had.\n */\nexport interface CrmRouteScope {\n level: 'site' | 'org'\n hostId: string\n orgId: string\n}\n\nexport function readCrmRouteScope(\n body: Record<string, unknown> | null | undefined,\n): CrmRouteScope | null {\n const hostId = String(body?.['hostId'] ?? '')\n .trim()\n .slice(0, 128)\n const orgId = String(body?.['orgId'] ?? '')\n .trim()\n .slice(0, 128)\n if (orgId) return { level: 'org', hostId, orgId }\n if (hostId) return { level: 'site', hostId, orgId: '' }\n return null\n}\n\n/**\n * Whether `uid` holds `data.manage` in `orgId`, as the permission catalog\n * resolves it: the member's role, a custom role and per-member overrides\n * (AGL-2843).\n *\n * Asked of `memberHasOrgPermission`, never of the `permissions` map\n * `resolveOrgPermissions` returns. That map is the legacy six keys plus the\n * keys plugins register (`ResolvedOrgPermissionSet`), and `data.manage` is a\n * catalog key, so it is never in it: a gate that read it there would refuse\n * everyone but staff. A lookup that fails answers `false`.\n */\nexport async function holdsDataManage(\n orgId: string | null | undefined,\n uid: string,\n): Promise<boolean> {\n if (!orgId) return false\n try {\n const membership = await resolveOrgMembership(uid, orgId)\n const member = membership?.member ?? null\n if (!member) return false\n return (await memberHasOrgPermission(orgId, member, 'data.manage')) === true\n } catch {\n return false\n }\n}\n\nexport interface OrgCaller {\n ok: true\n uid: string\n /** The token's address, or null for an account with none. */\n email: string | null\n /** The token's display name, `''` when it carries none. */\n name: string\n staff: boolean\n orgId: string\n org: Partial<AglynOrganization>\n}\n\nexport interface OrgCallerRefusal {\n ok: false\n status: number\n error: string\n}\n\n/** What the caller must hold over the whole organization. */\nexport type OrgCallerRequirement =\n /** `data.manage`, org-wide — the key the CRM is gated on. Staff pass. */\n | 'data.manage'\n /** A workspace owner or admin, org-wide. Staff acting alone do NOT pass. */\n | 'manage-org'\n\n/**\n * A verified session, org-wide reach in `orgId`, and the requirement — in\n * that order, the cheapest refusal first — then the org document, which\n * every org variant needs for the plan and the consent groups.\n *\n * Staff are admitted to the `data.manage` requirement as every CRM route\n * admits them, and refused the `manage-org` one as the erasure route\n * refuses them: the workspace is the controller of its people's data and\n * the instruction to erase has to come from it.\n */\nexport async function authorizeOrgCaller(\n req: PluginApiRequest,\n orgId: string,\n options: { needs: OrgCallerRequirement; refusal: string },\n): Promise<OrgCaller | OrgCallerRefusal> {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return { ok: false, status: 401, error: 'Unauthenticated' }\n let decoded: { uid: string; email?: string; name?: string; staff?: unknown }\n try {\n decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n } catch (error) {\n // A refused credential is the caller's 401. A failure to check one is\n // ours and keeps a 5xx, so an outage pages instead of reading as a bad\n // token (AGL-2852).\n if (isRefusedIdToken(error)) return { ok: false, status: 401, error: 'Unauthenticated' }\n console.error('[crm] the organization caller could not be verified', error)\n return { ok: false, status: 500, error: 'The sign-in could not be checked. Try again.' }\n }\n const staff = decoded.staff === true\n const membership = await resolveOrgPermissions(decoded.uid, { orgId }).catch(\n () => null,\n )\n const orgWide = Boolean(membership?.orgWide) && membership?.orgId === orgId\n const admitted =\n options.needs === 'data.manage'\n ? staff || (orgWide && (await holdsDataManage(orgId, decoded.uid)))\n : orgWide && canManageOrg(membership?.role ?? null)\n if (!admitted) return { ok: false, status: 403, error: options.refusal }\n const org = await getOrgDoc(orgId).catch(() => null)\n if (!org) return { ok: false, status: 404, error: 'Unknown organization' }\n return {\n ok: true,\n uid: decoded.uid,\n email: decoded.email ?? null,\n name: String(decoded.name ?? '')\n .trim()\n .slice(0, 120),\n staff,\n orgId,\n org,\n }\n}\n\n/**\n * Every site of the organization, by document id — the host set an\n * org-level act sweeps when it needs one (AGL-2634). One equality on\n * `hosts.orgId`, the query the erasure route already ran, which needs no\n * composite index.\n */\nexport async function orgHostIds(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<string[]> {\n const hosts = await firestore.collection('hosts').where('orgId', '==', orgId).get()\n return hosts.docs.map((doc: { id: string }) => String(doc.id))\n}\n"],"names":["canManageOrg","firebaseAdmin","getOrgDoc","memberHasOrgPermission","resolveOrgMembership","isRefusedIdToken","resolveOrgPermissions","readCrmRouteScope","body","hostId","String","trim","slice","orgId","level","holdsDataManage","uid","membership","member","authorizeOrgCaller","req","options","decoded","authorization","headers","idToken","startsWith","length","undefined","ok","status","error","app","auth","verifyIdToken","console","staff","catch","orgWide","Boolean","admitted","needs","role","refusal","org","email","name","orgHostIds","firestore","hosts","collection","where","get","docs","map","doc","id"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/org-caller.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHO IS CALLING AT THE ORGANIZATION LEVEL (AGL-2634).\n *\n * The CRM's host-routed actions — a stage move, a merge, an erasure, an\n * email — were written for the site hub: each resolves the org FROM the\n * site, checks the caller's role ON that site, and logs into that site's\n * feed. The organization-level hub (AGL-2630) mounts the same surfaces with\n * no site, and for a while every one of those actions ran \"as the record's\n * own capturing site\", which left a record nobody's site had captured with\n * nothing to run as. The org variant of each route authorizes HERE instead:\n * by the org the body names, for an org-wide member, with the permission\n * the whole CRM is gated on — the same three facts the org hub's own page\n * gate checks (`resolveOrgCrmAccess`), so what the console offers and what\n * the server admits cannot drift.\n *\n * A ROLE IS NOT A REACH. A site collaborator is a real member document\n * carrying `role: 'editor'`, and that role holds `data.manage` — so a gate\n * that read the permission alone would admit a contractor on one microsite\n * to act over every site in the agency. `orgWide` is checked first and\n * separately, and no permission grant restores it; this is the boundary the\n * host variants get for free from the site role, spelled out for the variant\n * that has no site.\n */\n\nimport { type AglynOrganization, canManageOrg, type PluginApiRequest } from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n getOrgDoc,\n memberHasOrgPermission,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\n\n/**\n * Which level a CRM route runs at, read off its body once.\n *\n * `orgId` present is the ORGANIZATION variant, whatever else the body\n * carries: the org-level console always names its org, and a `hostId`\n * beside it is the record's own site or the site an email leaves from —\n * never what the caller is authorized against. `hostId` alone is the site\n * variant every route has always had.\n */\nexport interface CrmRouteScope {\n level: 'site' | 'org'\n hostId: string\n orgId: string\n}\n\nexport function readCrmRouteScope(\n body: Record<string, unknown> | null | undefined,\n): CrmRouteScope | null {\n const hostId = String(body?.['hostId'] ?? '')\n .trim()\n .slice(0, 128)\n const orgId = String(body?.['orgId'] ?? '')\n .trim()\n .slice(0, 128)\n if (orgId) return { level: 'org', hostId, orgId }\n if (hostId) return { level: 'site', hostId, orgId: '' }\n return null\n}\n\n/**\n * Whether `uid` holds `data.manage` in `orgId`, as the permission catalog\n * resolves it: the member's role, a custom role and per-member overrides\n * (AGL-2843).\n *\n * Asked of `memberHasOrgPermission`, never of the `permissions` map\n * `resolveOrgPermissions` returns. That map is the legacy six keys plus the\n * keys plugins register (`ResolvedOrgPermissionSet`), and `data.manage` is a\n * catalog key, so it is never in it: a gate that read it there would refuse\n * everyone but staff. A lookup that fails answers `false`.\n */\nexport async function holdsDataManage(\n orgId: string | null | undefined,\n uid: string,\n): Promise<boolean> {\n if (!orgId) return false\n try {\n const membership = await resolveOrgMembership(uid, orgId)\n const member = membership?.member ?? null\n if (!member) return false\n return (await memberHasOrgPermission(orgId, member, 'data.manage')) === true\n } catch {\n return false\n }\n}\n\nexport interface OrgCaller {\n ok: true\n uid: string\n /** The token's address, or null for an account with none. */\n email: string | null\n /** The token's display name, `''` when it carries none. */\n name: string\n staff: boolean\n orgId: string\n org: Partial<AglynOrganization>\n}\n\nexport interface OrgCallerRefusal {\n ok: false\n status: number\n error: string\n}\n\n/** What the caller must hold over the whole organization. */\nexport type OrgCallerRequirement =\n /** `data.manage`, org-wide — the key the CRM is gated on. Staff pass. */\n | 'data.manage'\n /** A workspace owner or admin, org-wide. Staff acting alone do NOT pass. */\n | 'manage-org'\n /**\n * \"Privacy requests\" (`org.privacy`, AGL-4047), org-wide — who may file a\n * person's erasure. On for owners and admins by default. Staff acting\n * alone do NOT pass.\n */\n | 'org.privacy'\n\n/**\n * Whether `uid` holds a catalog key in `orgId` as the catalog resolves it —\n * their role, a custom role and per-member overrides — asked of\n * `memberHasOrgPermission`. A lookup that fails answers `false`.\n */\nexport async function holdsOrgPermission(\n orgId: string | null | undefined,\n uid: string,\n permission: 'org.privacy',\n): Promise<boolean> {\n if (!orgId) return false\n try {\n const membership = await resolveOrgMembership(uid, orgId)\n const member = membership?.member ?? null\n if (!member) return false\n return (await memberHasOrgPermission(orgId, member, permission)) === true\n } catch {\n return false\n }\n}\n\n/**\n * A verified session, org-wide reach in `orgId`, and the requirement — in\n * that order, the cheapest refusal first — then the org document, which\n * every org variant needs for the plan and the consent groups.\n *\n * Staff are admitted to the `data.manage` requirement as every CRM route\n * admits them, and refused the `manage-org` and `org.privacy` ones as the\n * erasure route refuses them: the workspace is the controller of its\n * people's data and the instruction to erase has to come from it.\n */\nexport async function authorizeOrgCaller(\n req: PluginApiRequest,\n orgId: string,\n options: { needs: OrgCallerRequirement; refusal: string },\n): Promise<OrgCaller | OrgCallerRefusal> {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return { ok: false, status: 401, error: 'Unauthenticated' }\n let decoded: { uid: string; email?: string; name?: string; staff?: unknown }\n try {\n decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n } catch (error) {\n // A refused credential is the caller's 401. A failure to check one is\n // ours and keeps a 5xx, so an outage pages instead of reading as a bad\n // token (AGL-2852).\n if (isRefusedIdToken(error)) return { ok: false, status: 401, error: 'Unauthenticated' }\n console.error('[crm] the organization caller could not be verified', error)\n return { ok: false, status: 500, error: 'The sign-in could not be checked. Try again.' }\n }\n const staff = decoded.staff === true\n const membership = await resolveOrgPermissions(decoded.uid, { orgId }).catch(\n () => null,\n )\n const orgWide = Boolean(membership?.orgWide) && membership?.orgId === orgId\n const admitted =\n options.needs === 'data.manage'\n ? staff || (orgWide && (await holdsDataManage(orgId, decoded.uid)))\n : options.needs === 'org.privacy'\n ? orgWide && (await holdsOrgPermission(orgId, decoded.uid, 'org.privacy'))\n : orgWide && canManageOrg(membership?.role ?? null)\n if (!admitted) return { ok: false, status: 403, error: options.refusal }\n const org = await getOrgDoc(orgId).catch(() => null)\n if (!org) return { ok: false, status: 404, error: 'Unknown organization' }\n return {\n ok: true,\n uid: decoded.uid,\n email: decoded.email ?? null,\n name: String(decoded.name ?? '')\n .trim()\n .slice(0, 120),\n staff,\n orgId,\n org,\n }\n}\n\n/**\n * Every site of the organization, by document id — the host set an\n * org-level act sweeps when it needs one (AGL-2634). One equality on\n * `hosts.orgId`, the query the erasure route already ran, which needs no\n * composite index.\n */\nexport async function orgHostIds(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<string[]> {\n const hosts = await firestore.collection('hosts').where('orgId', '==', orgId).get()\n return hosts.docs.map((doc: { id: string }) => String(doc.id))\n}\n"],"names":["canManageOrg","firebaseAdmin","getOrgDoc","memberHasOrgPermission","resolveOrgMembership","isRefusedIdToken","resolveOrgPermissions","readCrmRouteScope","body","hostId","String","trim","slice","orgId","level","holdsDataManage","uid","membership","member","holdsOrgPermission","permission","authorizeOrgCaller","req","options","decoded","authorization","headers","idToken","startsWith","length","undefined","ok","status","error","app","auth","verifyIdToken","console","staff","catch","orgWide","Boolean","admitted","needs","role","refusal","org","email","name","orgHostIds","firestore","hosts","collection","where","get","docs","map","doc","id"],"mappings":"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"}
|