@aglyn/plugins-crm 1.0.0-beta.284 → 1.0.0-beta.287

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/contact-associations-card.js +5 -7
  3. package/src/lib/components/contact-associations-card.js.map +1 -1
  4. package/src/lib/components/contact-list-columns.js +5 -7
  5. package/src/lib/components/contact-list-columns.js.map +1 -1
  6. package/src/lib/components/contact-timeline-card.js +4 -3
  7. package/src/lib/components/contact-timeline-card.js.map +1 -1
  8. package/src/lib/components/erase-person-action.d.ts +6 -4
  9. package/src/lib/components/erase-person-action.js +10 -8
  10. package/src/lib/components/erase-person-action.js.map +1 -1
  11. package/src/lib/components/reports/contacts-mix-card.js +4 -6
  12. package/src/lib/components/reports/contacts-mix-card.js.map +1 -1
  13. package/src/lib/components/reports/source-conversion-card.js +2 -4
  14. package/src/lib/components/reports/source-conversion-card.js.map +1 -1
  15. package/src/lib/components/use-can-erase-people.d.ts +32 -0
  16. package/src/lib/components/use-can-erase-people.js +56 -0
  17. package/src/lib/components/use-can-erase-people.js.map +1 -0
  18. package/src/lib/model/record-facts-disclosed.js +4 -6
  19. package/src/lib/model/record-facts-disclosed.js.map +1 -1
  20. package/src/lib/model/record-facts.js +3 -5
  21. package/src/lib/model/record-facts.js.map +1 -1
  22. package/src/lib/server/capture-contact.d.ts +5 -5
  23. package/src/lib/server/capture-contact.js +7 -6
  24. package/src/lib/server/capture-contact.js.map +1 -1
  25. package/src/lib/server/erase-person.d.ts +10 -7
  26. package/src/lib/server/erase-person.js +18 -13
  27. package/src/lib/server/erase-person.js.map +1 -1
  28. package/src/lib/server/org-caller.d.ts +16 -4
  29. package/src/lib/server/org-caller.js +20 -4
  30. 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 workspace admin or owner — `canManageOrg` on the caller's org role —
59
- * and nobody else. Not a site editor with the data permission, who can
60
- * detach a contact from their own site but has no standing to remove the
61
- * person from every site in the workspace; and not staff acting alone,
62
- * because the workspace is the controller of this data and the instruction
63
- * has to come from it. Staff who are also admins of the workspace pass on
64
- * that role, as anyone would.
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 { canManageOrg, CONTACT_ERASURE_REQUESTED_FIELD, normalizeContactEmail, PERSON_ERASURES_COLLECTION, personErasureConfirmationMatches, personErasureId } from "@aglyn/aglyn/server";
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 workspace admin or owner — `canManageOrg` on the caller's org role —
32
- * and nobody else. Not a site editor with the data permission, who can
33
- * detach a contact from their own site but has no standing to remove the
34
- * person from every site in the workspace; and not staff acting alone,
35
- * because the workspace is the controller of this data and the instruction
36
- * has to come from it. Staff who are also admins of the workspace pass on
37
- * that role, as anyone would.
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 = 'Only a workspace admin can erase a person from the workspace';
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: 'manage-org',
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
- if (!canManageOrg(membership.role)) {
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` one as the erasure route
97
- * refuses them: the workspace is the controller of its people's data and
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` one as the erasure route
87
- * refuses them: the workspace is the controller of its people's data and
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":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;CAsBC,GAED,SAAiCA,YAAY,QAA+B,sBAAqB;AACjG,SACEC,aAAa,EACbC,SAAS,EACTC,sBAAsB,EACtBC,oBAAoB,QACf,2BAA0B;AACjC,SAASC,gBAAgB,QAAQ,mDAAkD;AACnF,SAASC,qBAAqB,QAAQ,wCAAuC;AAiB7E,OAAO,SAASC,kBACdC,IAAgD;;IAEhD,MAAMC,SAASC,eAAOF,wBAAAA,IAAM,CAAC,SAAS,mBAAI,IACvCG,IAAI,GACJC,KAAK,CAAC,GAAG;IACZ,MAAMC,QAAQH,gBAAOF,wBAAAA,IAAM,CAAC,QAAQ,oBAAI,IACrCG,IAAI,GACJC,KAAK,CAAC,GAAG;IACZ,IAAIC,OAAO,OAAO;QAAEC,OAAO;QAAOL;QAAQI;IAAM;IAChD,IAAIJ,QAAQ,OAAO;QAAEK,OAAO;QAAQL;QAAQI,OAAO;IAAG;IACtD,OAAO;AACT;AAEA;;;;;;;;;;CAUC,GACD,OAAO,eAAeE,gBACpBF,KAAgC,EAChCG,GAAW;IAEX,IAAI,CAACH,OAAO,OAAO;IACnB,IAAI;;QACF,MAAMI,aAAa,MAAMb,qBAAqBY,KAAKH;QACnD,MAAMK,iBAASD,8BAAAA,WAAYC,MAAM,mBAAI;QACrC,IAAI,CAACA,QAAQ,OAAO;QACpB,OAAO,AAAC,MAAMf,uBAAuBU,OAAOK,QAAQ,mBAAoB;IAC1E,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAiCA;;;;CAIC,GACD,OAAO,eAAeC,mBACpBN,KAAgC,EAChCG,GAAW,EACXI,UAAyB;IAEzB,IAAI,CAACP,OAAO,OAAO;IACnB,IAAI;;QACF,MAAMI,aAAa,MAAMb,qBAAqBY,KAAKH;QACnD,MAAMK,iBAASD,8BAAAA,WAAYC,MAAM,mBAAI;QACrC,IAAI,CAACA,QAAQ,OAAO;QACpB,OAAO,AAAC,MAAMf,uBAAuBU,OAAOK,QAAQE,gBAAiB;IACvE,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA;;;;;;;;;CASC,GACD,OAAO,eAAeC,mBACpBC,GAAqB,EACrBT,KAAa,EACbU,OAAyD;QAE5BD,kCAiCpBE,gBACMA;IAlCf,MAAMC,gBAAgBf,QAAOY,6BAAAA,IAAII,OAAO,CAACD,aAAa,YAAzBH,6BAA6B;IAC1D,MAAMK,UAAUF,cAAcG,UAAU,CAAC,aACrCH,cAAcb,KAAK,CAAC,UAAUiB,MAAM,IACpCC;IACJ,IAAI,CAACH,SAAS,OAAO;QAAEI,IAAI;QAAOC,QAAQ;QAAKC,OAAO;IAAkB;IACxE,IAAIT;IACJ,IAAI;QACFA,UAAU,MAAMvB,cAAciC,GAAG,GAAGC,IAAI,GAAGC,aAAa,CAACT;IAC3D,EAAE,OAAOM,OAAO;QACd,sEAAsE;QACtE,uEAAuE;QACvE,oBAAoB;QACpB,IAAI5B,iBAAiB4B,QAAQ,OAAO;YAAEF,IAAI;YAAOC,QAAQ;YAAKC,OAAO;QAAkB;QACvFI,QAAQJ,KAAK,CAAC,uDAAuDA;QACrE,OAAO;YAAEF,IAAI;YAAOC,QAAQ;YAAKC,OAAO;QAA+C;IACzF;IACA,MAAMK,QAAQd,QAAQc,KAAK,KAAK;IAChC,MAAMrB,aAAa,MAAMX,sBAAsBkB,QAAQR,GAAG,EAAE;QAAEH;IAAM,GAAG0B,KAAK,CAC1E,IAAM;IAER,MAAMC,UAAUC,QAAQxB,8BAAAA,WAAYuB,OAAO,KAAKvB,CAAAA,8BAAAA,WAAYJ,KAAK,MAAKA;IACtE,MAAM6B,WACJnB,QAAQoB,KAAK,KAAK,gBACdL,SAAUE,WAAY,MAAMzB,gBAAgBF,OAAOW,QAAQR,GAAG,IAC9DO,QAAQoB,KAAK,KAAK,gBAChBH,WAAY,MAAMrB,mBAAmBN,OAAOW,QAAQR,GAAG,EAAE,iBACzDwB,WAAWxC,qBAAaiB,8BAAAA,WAAY2B,IAAI,mBAAI;IACpD,IAAI,CAACF,UAAU,OAAO;QAAEX,IAAI;QAAOC,QAAQ;QAAKC,OAAOV,QAAQsB,OAAO;IAAC;IACvE,MAAMC,MAAM,MAAM5C,UAAUW,OAAO0B,KAAK,CAAC,IAAM;IAC/C,IAAI,CAACO,KAAK,OAAO;QAAEf,IAAI;QAAOC,QAAQ;QAAKC,OAAO;IAAuB;IACzE,OAAO;QACLF,IAAI;QACJf,KAAKQ,QAAQR,GAAG;QAChB+B,KAAK,GAAEvB,iBAAAA,QAAQuB,KAAK,YAAbvB,iBAAiB;QACxBwB,MAAMtC,QAAOc,gBAAAA,QAAQwB,IAAI,YAAZxB,gBAAgB,IAC1Bb,IAAI,GACJC,KAAK,CAAC,GAAG;QACZ0B;QACAzB;QACAiC;IACF;AACF;AAEA;;;;;CAKC,GACD,OAAO,eAAeG,WACpBC,SAAsC,EACtCrC,KAAa;IAEb,MAAMsC,QAAQ,MAAMD,UAAUE,UAAU,CAAC,SAASC,KAAK,CAAC,SAAS,MAAMxC,OAAOyC,GAAG;IACjF,OAAOH,MAAMI,IAAI,CAACC,GAAG,CAAC,CAACC,MAAwB/C,OAAO+C,IAAIC,EAAE;AAC9D"}