@aglyn/tenant-data-admin 1.0.0-beta.149 → 1.0.0-beta.151

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/tenant-data-admin",
3
- "version": "1.0.0-beta.149",
3
+ "version": "1.0.0-beta.151",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,11 +25,11 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.149",
29
- "@aglyn/shared-util-email": "1.0.0-beta.149",
30
- "@aglyn/shared-util-fbserver": "1.0.0-beta.149",
31
- "@aglyn/shared-util-http": "1.0.0-beta.149",
32
- "@aglyn/shared-util-tools": "1.0.0-beta.149",
28
+ "@aglyn/aglyn": "1.0.0-beta.151",
29
+ "@aglyn/shared-util-email": "1.0.0-beta.151",
30
+ "@aglyn/shared-util-fbserver": "1.0.0-beta.151",
31
+ "@aglyn/shared-util-http": "1.0.0-beta.151",
32
+ "@aglyn/shared-util-tools": "1.0.0-beta.151",
33
33
  "@msgpack/msgpack": "^3.1.3",
34
34
  "@swc/helpers": "0.5.23",
35
35
  "sharp": "^0.35.3",
@@ -64,7 +64,7 @@ export declare function isEmailVerified(decoded: DecodedIdToken): boolean;
64
64
  /** Staff impersonation session (AGL-357/AGL-480): token minted with an
65
65
  * `impersonatedBy` claim. Exempt from the email-verification gate. */
66
66
  export declare function isImpersonationSession(decoded: DecodedIdToken): boolean;
67
- export declare function verifyConsoleIdToken(idToken: string, checkRevoked?: boolean): Promise<DecodedIdToken>;
67
+ export declare function verifyConsoleIdToken(idToken: string): Promise<DecodedIdToken>;
68
68
  /** 403 sent to callers whose email address is not yet verified (AGL-479). */
69
69
  export declare function emailUnverifiedResponse(): Response;
70
70
  export { firebaseAdmin };
@@ -224,12 +224,33 @@ function revocationCheckedAuth(target) {
224
224
  if (prop === 'verifyIdToken') {
225
225
  return async (...args)=>{
226
226
  const verify = Reflect.get(auth, prop, auth);
227
- // Spread, never `(token, checkRevoked)`: passing an explicit
228
- // `undefined` where the caller passed nothing changes what the SDK
229
- // sees, and a wrapper that alters the call it forwards is not a
230
- // wrapper.
231
- const decoded = await verify.apply(auth, args);
232
- if (args[1] === true) return decoded;
227
+ /*
228
+ * ONE ARGUMENT, ALWAYS (AGL-3229).
229
+ *
230
+ * `checkRevoked` used to be forwarded, and a caller passing `true`
231
+ * then returned here with the SDK's own answer and skipped the
232
+ * check below. That is not the stricter call it reads as — it is
233
+ * the BROKEN one, and for exactly the reason `revocationPool`
234
+ * exists: firebase-admin's `checkRevoked` runs
235
+ * `this.getUser(sub)` on the handle that verified the token, which
236
+ * for an SSO account is the project pool the uid is not in. The
237
+ * lookup throws `auth/user-not-found`, `id-token-refusal.ts` reads
238
+ * that as a bad credential, and `POST /api/auth/session` answered
239
+ * `401 Unauthenticated` to every tenant user — so no SSO account
240
+ * could mint the shared cookie, the sign-out tombstone it replaces
241
+ * survived every sign-in, and the console re-asked for credentials
242
+ * on every load.
243
+ *
244
+ * So the flag is now STRIPPED rather than honoured: the check
245
+ * below is the check, it is tenant-aware, it is cached, and it
246
+ * raises the same `auth/id-token-revoked` / `auth/user-disabled`
247
+ * codes the SDK's does. A wrapper that alters the call it forwards
248
+ * needs a reason, and "the forwarded call cannot answer the
249
+ * question for half our accounts" is one. Passing `true` is
250
+ * harmless and redundant; it is no longer a way around this.
251
+ */ const decoded = await verify.apply(auth, [
252
+ args[0]
253
+ ]);
233
254
  // The pool the TOKEN belongs to, which is not always the pool that
234
255
  // verified it — see `revocationPool`. Never `receiver`: the lookup
235
256
  // must not re-enter this proxy.
@@ -317,13 +338,18 @@ export function isEmailVerified(decoded) {
317
338
  * `impersonatedBy` claim. Exempt from the email-verification gate. */ export function isImpersonationSession(decoded) {
318
339
  return typeof decoded['impersonatedBy'] === 'string';
319
340
  }
320
- export async function verifyConsoleIdToken(idToken, checkRevoked) {
341
+ export async function verifyConsoleIdToken(idToken) {
321
342
  // `firebaseAdmin.app().auth()`, not a bare `getAuth` (AGL-1881): the raw
322
343
  // SDK handle skips the revocation check, and this helper's whole promise is
323
344
  // that it verifies "exactly like `auth().verifyIdToken`" plus the email
324
345
  // gate. A door that reads as the STRICTER one while being the looser one is
325
346
  // the worst shape available.
326
- const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken, checkRevoked);
347
+ //
348
+ // It used to forward a `checkRevoked` parameter, and there is nothing left
349
+ // for that to mean (AGL-3229): the handle's own check is the check, so the
350
+ // flag could only ever have selected the SDK's project-pool one, which is
351
+ // the one an SSO account cannot pass. Nothing passed it.
352
+ const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
327
353
  if (!isEmailVerified(decoded) && !isImpersonationSession(decoded)) {
328
354
  throw new EmailNotVerifiedError();
329
355
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/firebase-admin.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2022 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 * as Aglyn from '@aglyn/aglyn/server'\n// Initializes the firebase-admin default app (cert credential, RTDB URL,\n// service account, AppCheck) on module load. `firestoreDatabaseId` is the\n// FIRESTORE_DATABASE_ID override (AGL-1490): unset → `(default)` as before;\n// set → every accessor targets the named database (disaster-recovery cutover,\n// see docs/DISASTER_RECOVERY.md).\nimport { firestoreDatabaseId } from '@aglyn/shared-util-fbserver'\nimport { decode, encode } from '@msgpack/msgpack'\nimport { type App, getApp } from 'firebase-admin/app'\nimport {\n type BaseAuth,\n type DecodedIdToken,\n getAuth,\n} from 'firebase-admin/auth'\nimport { assertIdTokenNotRevoked } from './token-revocation'\nimport { getDatabase } from 'firebase-admin/database'\nimport { getRemoteConfig } from 'firebase-admin/remote-config'\nimport {\n FieldPath,\n FieldValue,\n type FirestoreDataConverter,\n Timestamp,\n getFirestore,\n} from 'firebase-admin/firestore'\nimport { getStorage } from 'firebase-admin/storage'\n\nfunction compress(value: any) {\n return Buffer.from(encode(value))\n}\nfunction decompress(value: any) {\n return decode(value)\n}\n\nexport const hostConverter: FirestoreDataConverter<Aglyn.AglynHost> = {\n toFirestore(data) {\n if (data.$id) delete data.$id\n // data.updatedAt = Timestamp.now()\n return data\n },\n fromFirestore(snapshot) {\n if (!snapshot.exists) return undefined\n const data = snapshot.data()\n data.$id = snapshot.id\n return data as Aglyn.AglynScreen\n },\n}\n\nexport const screenConverter: FirestoreDataConverter<Aglyn.AglynScreen> = {\n toFirestore(data) {\n if (data.$id) delete data.$id\n data.updatedAt = Timestamp.now()\n return data\n },\n fromFirestore(snapshot) {\n if (!snapshot.exists) return undefined\n const data = snapshot.data()\n data.$id = snapshot.id\n return data as Aglyn.AglynScreen\n },\n}\n\nexport const screenVersionConverter: FirestoreDataConverter<Aglyn.AglynScreenVersion> =\n {\n toFirestore(data) {\n if (data.elements) {\n data.nodes = data.elements\n delete data.elements\n }\n if (data['bundleId']) {\n data.pluginId = data['bundleId']\n delete data['bundleId']\n }\n if (data.nodes) data.nodes = compress(data.nodes) as any\n if (data.$id) delete data.$id\n data.updatedAt = Timestamp.now()\n return data\n },\n fromFirestore(snapshot) {\n if (!snapshot.exists) return undefined\n const data = snapshot.data()\n if (data?.elements) {\n data.nodes = data.elements\n delete data.elements\n }\n if (data?.['bundleId']) {\n data.pluginId = data['bundleId']\n delete data['bundleId']\n }\n if (data?.nodes) {\n // Nodes saved while updateDoc bypassed the client converter are plain\n // maps rather than compressed bytes; decode only binary payloads.\n data.nodes = ArrayBuffer.isView(data.nodes)\n ? decompress(data.nodes)\n : data.nodes\n }\n data.$id = snapshot.id\n return data as Aglyn.AglynScreenVersion\n },\n }\n\nexport const layoutConverter: FirestoreDataConverter<Aglyn.AglynLayout> = {\n toFirestore(data) {\n if (data.$id) delete data.$id\n data.updatedAt = Timestamp.now()\n return data\n },\n fromFirestore(snapshot) {\n if (!snapshot.exists) return undefined\n const data = snapshot.data()\n data.$id = snapshot.id\n return data as Aglyn.AglynLayout\n },\n}\n\n// Layout versions persist nodes exactly like screen versions (compressed).\nexport const layoutVersionConverter =\n screenVersionConverter as unknown as FirestoreDataConverter<Aglyn.AglynLayoutVersion>\n\n/**\n * Compatibility facade replacing firebase-admin v14's removed namespace API\n * (`import * as admin from 'firebase-admin'`) so existing call sites\n * (`firebaseAdmin.app().firestore()`, `firebaseAdmin.firestore.FieldValue`,\n * `firebaseAdmin.database()`) keep working unchanged, backed internally by\n * the modular SDK.\n */\n/**\n * Auth wrapped so `verifyIdToken` also asks whether the account was revoked\n * (AGL-1881).\n *\n * WHY HERE, and not at the call sites: the audit found `checkRevoked` set on\n * 3 of 175 verifications, which is what a per-call-site opt-in converges to.\n * Every server door in this repo reaches auth through `firebaseAdmin.app()` —\n * all 117 console API routes, the marketplace/commerce/bookings plugin\n * servers, `authForPool`, `release-flags` — so this ONE function is where the\n * question can be asked once and be true everywhere, including on routes\n * written next year by someone who has never read AGL-1881.\n * `token-revocation.spec.ts` pins that the wrapping is real; the guard in\n * `token-revocation-coverage.spec.ts` pins that no future door bypasses it.\n *\n * A Proxy rather than a hand-written facade because `BaseAuth` has ~40\n * methods and a facade that listed 39 of them would silently drop the\n * fortieth. Only `verifyIdToken` and `tenantManager` are intercepted;\n * everything else is the SDK's own method, bound to the SDK's own instance.\n *\n * `tenantManager` is wrapped recursively because a GCIP tenant's\n * `TenantAwareAuth` is a DIFFERENT auth object with its own `verifyIdToken`,\n * and five console routes verify SSO tokens through it. Unwrapped, those five\n * would be exactly the holes this exists to close.\n *\n * An explicit `checkRevoked: true` is left alone: firebase-admin then runs the\n * same three checks itself, and doing both would buy one answer with two\n * round trips.\n */\n/**\n * The pool to ask about a verified token's account (AGL-2486).\n *\n * ## The bug this exists to fix\n *\n * `BaseAuth.verifyIdToken` does NOT reject a token minted in a GCIP tenant.\n * Only `TenantAwareAuth` overrides it to compare `firebase.tenant` against\n * its own id; the project-level handle checks a signature, an issuer and an\n * audience, all of which are project-wide. So an SSO user's ID token verifies\n * perfectly through `firebaseAdmin.app().auth()` — and every console API\n * route verifies exactly that way.\n *\n * The revocation check then ran `getUser(uid)` on that same project-level\n * handle. A tenant uid is not in the project pool: measured on production\n * 2026-08-22, a staff account in `aglyn-org-y5v14` verified through the\n * project handle, and a project-level `getUser` of the uid that token carries\n * threw `auth/user-not-found`. `assertIdTokenNotRevoked` reads \"not found\" as\n * \"account deleted\" — correctly, and fail-CLOSED by design — and threw\n * `auth/id-token-revoked`. Every SSO user was therefore refused at every door\n * that verifies a Bearer token, with whatever the route's catch-all produced;\n * on `/api/presence/token` that was a 500 and live co-editing simply never\n * started for them.\n *\n * ## Why the existing guard did not catch it\n *\n * `no-project-level-auth-lookup.spec.ts` reads the RECEIVER NAME of a\n * `.getUser(` call, and `assertIdTokenNotRevoked` names its parameter `pool`\n * precisely so that it passes. The name was honest about the contract and\n * said nothing about the ARGUMENT, which was the project-level handle at the\n * only call site there is. A guard that reads a name proves a name.\n *\n * ## What this does\n *\n * Sends the lookup to the pool named by the token's own `firebase.tenant`\n * claim, so the contract `assertIdTokenNotRevoked` documents (\"the lookup is\n * asked of the pool the token belongs to\") becomes structurally true instead\n * of being an assumption about the caller. One place, so a route written next\n * year inherits it — the same reasoning that put the revocation check here.\n *\n * Returns the handle unchanged for a project-pool token and for an auth\n * already scoped to that same tenant.\n *\n * Returns NULL when the token names a tenant this handle cannot reach — and\n * the caller then SKIPS the check rather than asking the wrong pool. That is\n * the whole lesson of this bug: \"not found in the pool I happened to ask\" is\n * not evidence that an account was deleted, it is evidence that the question\n * went to the wrong place, and `assertIdTokenNotRevoked` already models \"we\n * could not ask\" as fail-open. Falling back to the verifying handle would\n * re-create the exact outage in the one configuration where routing fails.\n * Unreachable in practice — the project-level `Auth` always has a\n * `tenantManager`, and a `TenantAwareAuth` holding a token for a DIFFERENT\n * tenant has already thrown `auth/mismatching-tenant-id` before this runs.\n *\n * The tenant handle is deliberately NOT wrapped in `revocationCheckedAuth`:\n * it is used only for `getUser`, and re-wrapping would put the revocation\n * check inside the revocation check.\n */\nfunction revocationPool(\n auth: object,\n decoded: DecodedIdToken,\n): Pick<BaseAuth, 'getUser'> | null {\n const self = auth as unknown as Pick<BaseAuth, 'getUser'>\n const tenantId = decoded?.firebase?.tenant\n // `typeof`, not a truthy test: `strictNullChecks` is off repo-wide, so a\n // bare `!tenantId` narrows nothing and would also swallow a real id.\n if (typeof tenantId !== 'string' || !tenantId) return self\n // Already the right pool — a route that verified through\n // `authForPool(tenantId)` has nothing to switch to, and `TenantAwareAuth`\n // has no `tenantManager` to ask anyway.\n if ((auth as { tenantId?: string }).tenantId === tenantId) return self\n const tenantManager = (auth as { tenantManager?: () => unknown })\n .tenantManager\n if (typeof tenantManager !== 'function') return null\n try {\n const manager = tenantManager.call(auth) as {\n authForTenant: (id: string) => Pick<BaseAuth, 'getUser'>\n }\n return manager.authForTenant(tenantId)\n } catch {\n return null\n }\n}\n\nfunction revocationCheckedAuth<T extends object>(target: T): T {\n return new Proxy(target, {\n get(auth, prop, receiver) {\n if (prop === 'verifyIdToken') {\n return async (...args: unknown[]) => {\n const verify = Reflect.get(auth, prop, auth) as (\n ...a: unknown[]\n ) => Promise<DecodedIdToken>\n // Spread, never `(token, checkRevoked)`: passing an explicit\n // `undefined` where the caller passed nothing changes what the SDK\n // sees, and a wrapper that alters the call it forwards is not a\n // wrapper.\n const decoded = await verify.apply(auth, args)\n if (args[1] === true) return decoded\n // The pool the TOKEN belongs to, which is not always the pool that\n // verified it — see `revocationPool`. Never `receiver`: the lookup\n // must not re-enter this proxy.\n const pool = revocationPool(auth, decoded)\n // `=== null`, not `!pool`: `strictNullChecks` is off repo-wide, so\n // a falsy test narrows nothing here.\n if (pool === null) {\n console.warn(\n '[auth] revocation check skipped: no handle for tenant',\n decoded?.firebase?.tenant,\n )\n return decoded\n }\n await assertIdTokenNotRevoked(pool, decoded)\n return decoded\n }\n }\n if (prop === 'tenantManager') {\n return (...args: unknown[]) => {\n const manager = (\n Reflect.get(auth, prop, auth) as (...a: unknown[]) => object\n ).apply(auth, args)\n return new Proxy(manager, {\n get(mgr, key, mgrReceiver) {\n if (key === 'authForTenant') {\n return (...tenantArgs: unknown[]) =>\n revocationCheckedAuth(\n (\n Reflect.get(mgr, key, mgr) as (...a: unknown[]) => object\n ).apply(mgr, tenantArgs),\n )\n }\n const value = Reflect.get(mgr, key, mgrReceiver)\n return typeof value === 'function' ? value.bind(mgr) : value\n },\n })\n }\n }\n const value = Reflect.get(auth, prop, receiver)\n // Bound to the SDK instance: firebase-admin's Auth keeps private state\n // on `this`, and an unbound method handed out through a Proxy loses it.\n return typeof value === 'function' ? value.bind(auth) : value\n },\n })\n}\n\nfunction wrapApp(app: App) {\n return {\n firestore: () => getFirestore(app, firestoreDatabaseId()),\n auth: () => revocationCheckedAuth(getAuth(app)),\n storage: () => getStorage(app),\n // Release-flag management (AGL-230): the staff admin flags API reads\n // and publishes the Remote Config template server-side.\n remoteConfig: () => getRemoteConfig(app),\n }\n}\n\nfunction firestoreNamespace() {\n return getFirestore(getApp(), firestoreDatabaseId())\n}\nfirestoreNamespace.FieldValue = FieldValue\nfirestoreNamespace.Timestamp = Timestamp\nfirestoreNamespace.FieldPath = FieldPath\n\nconst firebaseAdmin = {\n app: (name?: string) => wrapApp(name ? getApp(name) : getApp()),\n firestore: firestoreNamespace,\n database: () => getDatabase(getApp()),\n}\n\n/**\n * Email-verification gate (AGL-479). Email/password accounts must verify\n * their address before any console access; OAuth accounts arrive with\n * `email_verified: true`, so they pass untouched. `verifyConsoleIdToken`\n * verifies the ID token exactly like `auth().verifyIdToken` and additionally\n * throws `EmailNotVerifiedError` when the address is unverified — verified\n * callers see identical behavior. Route handlers pair the raw verify with\n * `emailUnverifiedResponse()` (a 403) so the denial stays distinct from an\n * invalid-token 401. Fails closed: a token with no `email_verified` claim\n * (e.g. some custom-token sign-ins) is treated as unverified.\n *\n * Exception (AGL-480): staff impersonation sessions carry an `impersonatedBy`\n * claim (minted by /api/admin/impersonate). Staff have already authenticated\n * and the act is audited, so the impersonated account's own verification\n * state must not gate the support session — otherwise staff can't reach a\n * brand-new, still-unverified owner, the exact account most likely to need\n * help. `isImpersonationSession` gates that exemption.\n */\nexport class EmailNotVerifiedError extends Error {\n readonly code = 'auth/email-not-verified'\n constructor() {\n super('Email address not verified')\n this.name = 'EmailNotVerifiedError'\n }\n}\n\nexport function isEmailVerified(decoded: DecodedIdToken): boolean {\n return decoded.email_verified === true\n}\n\n/** Staff impersonation session (AGL-357/AGL-480): token minted with an\n * `impersonatedBy` claim. Exempt from the email-verification gate. */\nexport function isImpersonationSession(decoded: DecodedIdToken): boolean {\n return typeof decoded['impersonatedBy'] === 'string'\n}\n\nexport async function verifyConsoleIdToken(\n idToken: string,\n checkRevoked?: boolean,\n): Promise<DecodedIdToken> {\n // `firebaseAdmin.app().auth()`, not a bare `getAuth` (AGL-1881): the raw\n // SDK handle skips the revocation check, and this helper's whole promise is\n // that it verifies \"exactly like `auth().verifyIdToken`\" plus the email\n // gate. A door that reads as the STRICTER one while being the looser one is\n // the worst shape available.\n const decoded = await firebaseAdmin\n .app()\n .auth()\n .verifyIdToken(idToken, checkRevoked)\n if (!isEmailVerified(decoded) && !isImpersonationSession(decoded)) {\n throw new EmailNotVerifiedError()\n }\n return decoded\n}\n\n/** 403 sent to callers whose email address is not yet verified (AGL-479). */\nexport function emailUnverifiedResponse(): Response {\n return Response.json(\n { error: 'Verify your email to continue', reason: 'email-unverified' },\n { status: 403 },\n )\n}\n\nexport { firebaseAdmin }\nexport default firebaseAdmin\n"],"names":["firestoreDatabaseId","decode","encode","getApp","getAuth","assertIdTokenNotRevoked","getDatabase","getRemoteConfig","FieldPath","FieldValue","Timestamp","getFirestore","getStorage","compress","value","Buffer","from","decompress","hostConverter","toFirestore","data","$id","fromFirestore","snapshot","exists","undefined","id","screenConverter","updatedAt","now","screenVersionConverter","elements","nodes","pluginId","ArrayBuffer","isView","layoutConverter","layoutVersionConverter","revocationPool","auth","decoded","self","tenantId","firebase","tenant","tenantManager","manager","call","authForTenant","revocationCheckedAuth","target","Proxy","get","prop","receiver","args","verify","Reflect","apply","pool","console","warn","mgr","key","mgrReceiver","tenantArgs","bind","wrapApp","app","firestore","storage","remoteConfig","firestoreNamespace","firebaseAdmin","name","database","EmailNotVerifiedError","Error","code","isEmailVerified","email_verified","isImpersonationSession","verifyConsoleIdToken","idToken","checkRevoked","verifyIdToken","emailUnverifiedResponse","Response","json","error","reason","status"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/firebase-admin.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2022 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 * as Aglyn from '@aglyn/aglyn/server'\n// Initializes the firebase-admin default app (cert credential, RTDB URL,\n// service account, AppCheck) on module load. `firestoreDatabaseId` is the\n// FIRESTORE_DATABASE_ID override (AGL-1490): unset → `(default)` as before;\n// set → every accessor targets the named database (disaster-recovery cutover,\n// see docs/DISASTER_RECOVERY.md).\nimport { firestoreDatabaseId } from '@aglyn/shared-util-fbserver'\nimport { decode, encode } from '@msgpack/msgpack'\nimport { type App, getApp } from 'firebase-admin/app'\nimport {\n type BaseAuth,\n type DecodedIdToken,\n getAuth,\n} from 'firebase-admin/auth'\nimport { assertIdTokenNotRevoked } from './token-revocation'\nimport { getDatabase } from 'firebase-admin/database'\nimport { getRemoteConfig } from 'firebase-admin/remote-config'\nimport {\n FieldPath,\n FieldValue,\n type FirestoreDataConverter,\n Timestamp,\n getFirestore,\n} from 'firebase-admin/firestore'\nimport { getStorage } from 'firebase-admin/storage'\n\nfunction compress(value: any) {\n return Buffer.from(encode(value))\n}\nfunction decompress(value: any) {\n return decode(value)\n}\n\nexport const hostConverter: FirestoreDataConverter<Aglyn.AglynHost> = {\n toFirestore(data) {\n if (data.$id) delete data.$id\n // data.updatedAt = Timestamp.now()\n return data\n },\n fromFirestore(snapshot) {\n if (!snapshot.exists) return undefined\n const data = snapshot.data()\n data.$id = snapshot.id\n return data as Aglyn.AglynScreen\n },\n}\n\nexport const screenConverter: FirestoreDataConverter<Aglyn.AglynScreen> = {\n toFirestore(data) {\n if (data.$id) delete data.$id\n data.updatedAt = Timestamp.now()\n return data\n },\n fromFirestore(snapshot) {\n if (!snapshot.exists) return undefined\n const data = snapshot.data()\n data.$id = snapshot.id\n return data as Aglyn.AglynScreen\n },\n}\n\nexport const screenVersionConverter: FirestoreDataConverter<Aglyn.AglynScreenVersion> =\n {\n toFirestore(data) {\n if (data.elements) {\n data.nodes = data.elements\n delete data.elements\n }\n if (data['bundleId']) {\n data.pluginId = data['bundleId']\n delete data['bundleId']\n }\n if (data.nodes) data.nodes = compress(data.nodes) as any\n if (data.$id) delete data.$id\n data.updatedAt = Timestamp.now()\n return data\n },\n fromFirestore(snapshot) {\n if (!snapshot.exists) return undefined\n const data = snapshot.data()\n if (data?.elements) {\n data.nodes = data.elements\n delete data.elements\n }\n if (data?.['bundleId']) {\n data.pluginId = data['bundleId']\n delete data['bundleId']\n }\n if (data?.nodes) {\n // Nodes saved while updateDoc bypassed the client converter are plain\n // maps rather than compressed bytes; decode only binary payloads.\n data.nodes = ArrayBuffer.isView(data.nodes)\n ? decompress(data.nodes)\n : data.nodes\n }\n data.$id = snapshot.id\n return data as Aglyn.AglynScreenVersion\n },\n }\n\nexport const layoutConverter: FirestoreDataConverter<Aglyn.AglynLayout> = {\n toFirestore(data) {\n if (data.$id) delete data.$id\n data.updatedAt = Timestamp.now()\n return data\n },\n fromFirestore(snapshot) {\n if (!snapshot.exists) return undefined\n const data = snapshot.data()\n data.$id = snapshot.id\n return data as Aglyn.AglynLayout\n },\n}\n\n// Layout versions persist nodes exactly like screen versions (compressed).\nexport const layoutVersionConverter =\n screenVersionConverter as unknown as FirestoreDataConverter<Aglyn.AglynLayoutVersion>\n\n/**\n * Compatibility facade replacing firebase-admin v14's removed namespace API\n * (`import * as admin from 'firebase-admin'`) so existing call sites\n * (`firebaseAdmin.app().firestore()`, `firebaseAdmin.firestore.FieldValue`,\n * `firebaseAdmin.database()`) keep working unchanged, backed internally by\n * the modular SDK.\n */\n/**\n * Auth wrapped so `verifyIdToken` also asks whether the account was revoked\n * (AGL-1881).\n *\n * WHY HERE, and not at the call sites: the audit found `checkRevoked` set on\n * 3 of 175 verifications, which is what a per-call-site opt-in converges to.\n * Every server door in this repo reaches auth through `firebaseAdmin.app()` —\n * all 117 console API routes, the marketplace/commerce/bookings plugin\n * servers, `authForPool`, `release-flags` — so this ONE function is where the\n * question can be asked once and be true everywhere, including on routes\n * written next year by someone who has never read AGL-1881.\n * `token-revocation.spec.ts` pins that the wrapping is real; the guard in\n * `token-revocation-coverage.spec.ts` pins that no future door bypasses it.\n *\n * A Proxy rather than a hand-written facade because `BaseAuth` has ~40\n * methods and a facade that listed 39 of them would silently drop the\n * fortieth. Only `verifyIdToken` and `tenantManager` are intercepted;\n * everything else is the SDK's own method, bound to the SDK's own instance.\n *\n * `tenantManager` is wrapped recursively because a GCIP tenant's\n * `TenantAwareAuth` is a DIFFERENT auth object with its own `verifyIdToken`,\n * and five console routes verify SSO tokens through it. Unwrapped, those five\n * would be exactly the holes this exists to close.\n *\n * An explicit `checkRevoked: true` is left alone: firebase-admin then runs the\n * same three checks itself, and doing both would buy one answer with two\n * round trips.\n */\n/**\n * The pool to ask about a verified token's account (AGL-2486).\n *\n * ## The bug this exists to fix\n *\n * `BaseAuth.verifyIdToken` does NOT reject a token minted in a GCIP tenant.\n * Only `TenantAwareAuth` overrides it to compare `firebase.tenant` against\n * its own id; the project-level handle checks a signature, an issuer and an\n * audience, all of which are project-wide. So an SSO user's ID token verifies\n * perfectly through `firebaseAdmin.app().auth()` — and every console API\n * route verifies exactly that way.\n *\n * The revocation check then ran `getUser(uid)` on that same project-level\n * handle. A tenant uid is not in the project pool: measured on production\n * 2026-08-22, a staff account in `aglyn-org-y5v14` verified through the\n * project handle, and a project-level `getUser` of the uid that token carries\n * threw `auth/user-not-found`. `assertIdTokenNotRevoked` reads \"not found\" as\n * \"account deleted\" — correctly, and fail-CLOSED by design — and threw\n * `auth/id-token-revoked`. Every SSO user was therefore refused at every door\n * that verifies a Bearer token, with whatever the route's catch-all produced;\n * on `/api/presence/token` that was a 500 and live co-editing simply never\n * started for them.\n *\n * ## Why the existing guard did not catch it\n *\n * `no-project-level-auth-lookup.spec.ts` reads the RECEIVER NAME of a\n * `.getUser(` call, and `assertIdTokenNotRevoked` names its parameter `pool`\n * precisely so that it passes. The name was honest about the contract and\n * said nothing about the ARGUMENT, which was the project-level handle at the\n * only call site there is. A guard that reads a name proves a name.\n *\n * ## What this does\n *\n * Sends the lookup to the pool named by the token's own `firebase.tenant`\n * claim, so the contract `assertIdTokenNotRevoked` documents (\"the lookup is\n * asked of the pool the token belongs to\") becomes structurally true instead\n * of being an assumption about the caller. One place, so a route written next\n * year inherits it — the same reasoning that put the revocation check here.\n *\n * Returns the handle unchanged for a project-pool token and for an auth\n * already scoped to that same tenant.\n *\n * Returns NULL when the token names a tenant this handle cannot reach — and\n * the caller then SKIPS the check rather than asking the wrong pool. That is\n * the whole lesson of this bug: \"not found in the pool I happened to ask\" is\n * not evidence that an account was deleted, it is evidence that the question\n * went to the wrong place, and `assertIdTokenNotRevoked` already models \"we\n * could not ask\" as fail-open. Falling back to the verifying handle would\n * re-create the exact outage in the one configuration where routing fails.\n * Unreachable in practice — the project-level `Auth` always has a\n * `tenantManager`, and a `TenantAwareAuth` holding a token for a DIFFERENT\n * tenant has already thrown `auth/mismatching-tenant-id` before this runs.\n *\n * The tenant handle is deliberately NOT wrapped in `revocationCheckedAuth`:\n * it is used only for `getUser`, and re-wrapping would put the revocation\n * check inside the revocation check.\n */\nfunction revocationPool(\n auth: object,\n decoded: DecodedIdToken,\n): Pick<BaseAuth, 'getUser'> | null {\n const self = auth as unknown as Pick<BaseAuth, 'getUser'>\n const tenantId = decoded?.firebase?.tenant\n // `typeof`, not a truthy test: `strictNullChecks` is off repo-wide, so a\n // bare `!tenantId` narrows nothing and would also swallow a real id.\n if (typeof tenantId !== 'string' || !tenantId) return self\n // Already the right pool — a route that verified through\n // `authForPool(tenantId)` has nothing to switch to, and `TenantAwareAuth`\n // has no `tenantManager` to ask anyway.\n if ((auth as { tenantId?: string }).tenantId === tenantId) return self\n const tenantManager = (auth as { tenantManager?: () => unknown })\n .tenantManager\n if (typeof tenantManager !== 'function') return null\n try {\n const manager = tenantManager.call(auth) as {\n authForTenant: (id: string) => Pick<BaseAuth, 'getUser'>\n }\n return manager.authForTenant(tenantId)\n } catch {\n return null\n }\n}\n\nfunction revocationCheckedAuth<T extends object>(target: T): T {\n return new Proxy(target, {\n get(auth, prop, receiver) {\n if (prop === 'verifyIdToken') {\n return async (...args: unknown[]) => {\n const verify = Reflect.get(auth, prop, auth) as (\n ...a: unknown[]\n ) => Promise<DecodedIdToken>\n /*\n * ONE ARGUMENT, ALWAYS (AGL-3229).\n *\n * `checkRevoked` used to be forwarded, and a caller passing `true`\n * then returned here with the SDK's own answer and skipped the\n * check below. That is not the stricter call it reads as — it is\n * the BROKEN one, and for exactly the reason `revocationPool`\n * exists: firebase-admin's `checkRevoked` runs\n * `this.getUser(sub)` on the handle that verified the token, which\n * for an SSO account is the project pool the uid is not in. The\n * lookup throws `auth/user-not-found`, `id-token-refusal.ts` reads\n * that as a bad credential, and `POST /api/auth/session` answered\n * `401 Unauthenticated` to every tenant user — so no SSO account\n * could mint the shared cookie, the sign-out tombstone it replaces\n * survived every sign-in, and the console re-asked for credentials\n * on every load.\n *\n * So the flag is now STRIPPED rather than honoured: the check\n * below is the check, it is tenant-aware, it is cached, and it\n * raises the same `auth/id-token-revoked` / `auth/user-disabled`\n * codes the SDK's does. A wrapper that alters the call it forwards\n * needs a reason, and \"the forwarded call cannot answer the\n * question for half our accounts\" is one. Passing `true` is\n * harmless and redundant; it is no longer a way around this.\n */\n const decoded = await verify.apply(auth, [args[0]])\n // The pool the TOKEN belongs to, which is not always the pool that\n // verified it — see `revocationPool`. Never `receiver`: the lookup\n // must not re-enter this proxy.\n const pool = revocationPool(auth, decoded)\n // `=== null`, not `!pool`: `strictNullChecks` is off repo-wide, so\n // a falsy test narrows nothing here.\n if (pool === null) {\n console.warn(\n '[auth] revocation check skipped: no handle for tenant',\n decoded?.firebase?.tenant,\n )\n return decoded\n }\n await assertIdTokenNotRevoked(pool, decoded)\n return decoded\n }\n }\n if (prop === 'tenantManager') {\n return (...args: unknown[]) => {\n const manager = (\n Reflect.get(auth, prop, auth) as (...a: unknown[]) => object\n ).apply(auth, args)\n return new Proxy(manager, {\n get(mgr, key, mgrReceiver) {\n if (key === 'authForTenant') {\n return (...tenantArgs: unknown[]) =>\n revocationCheckedAuth(\n (\n Reflect.get(mgr, key, mgr) as (...a: unknown[]) => object\n ).apply(mgr, tenantArgs),\n )\n }\n const value = Reflect.get(mgr, key, mgrReceiver)\n return typeof value === 'function' ? value.bind(mgr) : value\n },\n })\n }\n }\n const value = Reflect.get(auth, prop, receiver)\n // Bound to the SDK instance: firebase-admin's Auth keeps private state\n // on `this`, and an unbound method handed out through a Proxy loses it.\n return typeof value === 'function' ? value.bind(auth) : value\n },\n })\n}\n\nfunction wrapApp(app: App) {\n return {\n firestore: () => getFirestore(app, firestoreDatabaseId()),\n auth: () => revocationCheckedAuth(getAuth(app)),\n storage: () => getStorage(app),\n // Release-flag management (AGL-230): the staff admin flags API reads\n // and publishes the Remote Config template server-side.\n remoteConfig: () => getRemoteConfig(app),\n }\n}\n\nfunction firestoreNamespace() {\n return getFirestore(getApp(), firestoreDatabaseId())\n}\nfirestoreNamespace.FieldValue = FieldValue\nfirestoreNamespace.Timestamp = Timestamp\nfirestoreNamespace.FieldPath = FieldPath\n\nconst firebaseAdmin = {\n app: (name?: string) => wrapApp(name ? getApp(name) : getApp()),\n firestore: firestoreNamespace,\n database: () => getDatabase(getApp()),\n}\n\n/**\n * Email-verification gate (AGL-479). Email/password accounts must verify\n * their address before any console access; OAuth accounts arrive with\n * `email_verified: true`, so they pass untouched. `verifyConsoleIdToken`\n * verifies the ID token exactly like `auth().verifyIdToken` and additionally\n * throws `EmailNotVerifiedError` when the address is unverified — verified\n * callers see identical behavior. Route handlers pair the raw verify with\n * `emailUnverifiedResponse()` (a 403) so the denial stays distinct from an\n * invalid-token 401. Fails closed: a token with no `email_verified` claim\n * (e.g. some custom-token sign-ins) is treated as unverified.\n *\n * Exception (AGL-480): staff impersonation sessions carry an `impersonatedBy`\n * claim (minted by /api/admin/impersonate). Staff have already authenticated\n * and the act is audited, so the impersonated account's own verification\n * state must not gate the support session — otherwise staff can't reach a\n * brand-new, still-unverified owner, the exact account most likely to need\n * help. `isImpersonationSession` gates that exemption.\n */\nexport class EmailNotVerifiedError extends Error {\n readonly code = 'auth/email-not-verified'\n constructor() {\n super('Email address not verified')\n this.name = 'EmailNotVerifiedError'\n }\n}\n\nexport function isEmailVerified(decoded: DecodedIdToken): boolean {\n return decoded.email_verified === true\n}\n\n/** Staff impersonation session (AGL-357/AGL-480): token minted with an\n * `impersonatedBy` claim. Exempt from the email-verification gate. */\nexport function isImpersonationSession(decoded: DecodedIdToken): boolean {\n return typeof decoded['impersonatedBy'] === 'string'\n}\n\nexport async function verifyConsoleIdToken(\n idToken: string,\n): Promise<DecodedIdToken> {\n // `firebaseAdmin.app().auth()`, not a bare `getAuth` (AGL-1881): the raw\n // SDK handle skips the revocation check, and this helper's whole promise is\n // that it verifies \"exactly like `auth().verifyIdToken`\" plus the email\n // gate. A door that reads as the STRICTER one while being the looser one is\n // the worst shape available.\n //\n // It used to forward a `checkRevoked` parameter, and there is nothing left\n // for that to mean (AGL-3229): the handle's own check is the check, so the\n // flag could only ever have selected the SDK's project-pool one, which is\n // the one an SSO account cannot pass. Nothing passed it.\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!isEmailVerified(decoded) && !isImpersonationSession(decoded)) {\n throw new EmailNotVerifiedError()\n }\n return decoded\n}\n\n/** 403 sent to callers whose email address is not yet verified (AGL-479). */\nexport function emailUnverifiedResponse(): Response {\n return Response.json(\n { error: 'Verify your email to continue', reason: 'email-unverified' },\n { status: 403 },\n )\n}\n\nexport { firebaseAdmin }\nexport default firebaseAdmin\n"],"names":["firestoreDatabaseId","decode","encode","getApp","getAuth","assertIdTokenNotRevoked","getDatabase","getRemoteConfig","FieldPath","FieldValue","Timestamp","getFirestore","getStorage","compress","value","Buffer","from","decompress","hostConverter","toFirestore","data","$id","fromFirestore","snapshot","exists","undefined","id","screenConverter","updatedAt","now","screenVersionConverter","elements","nodes","pluginId","ArrayBuffer","isView","layoutConverter","layoutVersionConverter","revocationPool","auth","decoded","self","tenantId","firebase","tenant","tenantManager","manager","call","authForTenant","revocationCheckedAuth","target","Proxy","get","prop","receiver","args","verify","Reflect","apply","pool","console","warn","mgr","key","mgrReceiver","tenantArgs","bind","wrapApp","app","firestore","storage","remoteConfig","firestoreNamespace","firebaseAdmin","name","database","EmailNotVerifiedError","Error","code","isEmailVerified","email_verified","isImpersonationSession","verifyConsoleIdToken","idToken","verifyIdToken","emailUnverifiedResponse","Response","json","error","reason","status"],"mappings":"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"}
@@ -52,7 +52,7 @@ export type UpsertHostContactVerdict = {
52
52
  * a field the facet grows is a field this option may carry the moment the
53
53
  * pick names it, and one it does not name is refused at compile time.
54
54
  */
55
- export type UpsertHostContactFacet = Partial<Pick<ContactFacet, 'phone' | 'jobTitle' | 'companyId' | 'address' | 'ownerUid' | 'lifecycleStage' | 'custom'>>;
55
+ export type UpsertHostContactFacet = Partial<Pick<ContactFacet, 'phone' | 'jobTitle' | 'companyId' | 'companyName' | 'address' | 'ownerUid' | 'lifecycleStage' | 'custom'>>;
56
56
  /**
57
57
  * The profile fields a door may hand this function (AGL-2596): the parts of
58
58
  * a person's record that no capture surface collects — the console's create
@@ -58,6 +58,11 @@ import { consentGroupForSite, getOrgForHost, orgDataCollectionForHost } from "./
58
58
  if (typeof input.companyId === 'string' && input.companyId.trim()) {
59
59
  out.companyId = input.companyId.trim().slice(0, 128);
60
60
  }
61
+ // The name as this holder knows it, beside the link — the merge fields
62
+ // read `facet.companyName`, and a link with no name renders as nothing.
63
+ if (typeof input.companyName === 'string' && input.companyName.trim()) {
64
+ out.companyName = input.companyName.trim().slice(0, 120);
65
+ }
61
66
  if (typeof input.ownerUid === 'string' && input.ownerUid.trim()) {
62
67
  out.ownerUid = input.ownerUid.trim().slice(0, 128);
63
68
  }
@@ -289,6 +294,8 @@ export async function upsertHostContact(options) {
289
294
  const mirror = link ? contactCompanyMirrorValue(link) : undefined;
290
295
  await docSnapshot.ref.set(_extends({}, merged.name ? nameSearchFields(merged.name) : {}, profile.phone ? {
291
296
  phone: profile.phone
297
+ } : {}, profile.companyName ? {
298
+ companyName: profile.companyName
292
299
  } : {}, mirror !== undefined ? {
293
300
  [CONTACT_COMPANY_IDS_FIELD]: mirror
294
301
  } : {}, {
@@ -437,6 +444,8 @@ export async function upsertHostContact(options) {
437
444
  email
438
445
  }, options.name ? nameSearchFields(options.name.slice(0, 120)) : {}, profile.phone ? {
439
446
  phone: profile.phone
447
+ } : {}, profile.companyName ? {
448
+ companyName: profile.companyName
440
449
  } : {}, profile.companyId ? {
441
450
  [CONTACT_COMPANY_IDS_FIELD]: [
442
451
  profile.companyId
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/upsert-contact.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 type AglynPostalAddress,\n CAPTURED_BY_HOST_FIELD,\n checkCrmRecordsQuota,\n consentGroupScope,\n CONTACT_FACETS_FIELD,\n CONTACT_FORM_IDS_CAP,\n CONTACT_FORM_IDS_FIELD,\n type ContactFacet,\n type ContactInteraction,\n type ContactLifecycleStage,\n type ContactSource,\n marketingConsentFieldsForGroup,\n mergeContactInteraction,\n normalizeCampaignIds,\n readContactFacet,\n normalizeContactEmail,\n ORG_SCOPE_TOKEN,\n} from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { firebaseAdmin } from './firebase-admin'\nimport { countCrmRecords } from './crm-records'\nimport { attributeOrderToEmail } from './email-revenue-attribution'\nimport { hostRefusesCaptureForErasure } from './email-suppression'\nimport {\n attributeCampaignConversion,\n type ResolvedCampaignTouch,\n} from './campaign-conversion-attribution'\nimport { nameSearchFields } from '@aglyn/aglyn/app-utils/name-search'\nimport {\n declineMarketingConsentFields,\n MARKETING_CONSENT_SOURCE_FIELD,\n type MarketingConsentSource,\n} from '@aglyn/aglyn/app-utils/marketing-consent'\n/*\n * The module paths, like `name-search` above, rather than the barrel: the\n * pure helpers this door leans on are exactly the ones a spec of the door\n * substitutes a fixture barrel for, and a fixture that has to re-export the\n * whole of `@aglyn/aglyn` to keep a normalizer reachable is a fixture that\n * drifts. A direct path is real in every harness.\n */\nimport {\n advanceContactLifecycleStage,\n CONTACT_COMPANY_IDS_FIELD,\n CONTACT_FIELD_KEY_PATTERN,\n type ContactCustomValue,\n CRM_COLLECTIONS,\n isContactLifecycleStage,\n planContactCompanyLink,\n readContactCompanyLink,\n} from '@aglyn/aglyn/app-utils/crm'\nimport {\n normalizeAddress,\n normalizePhone,\n} from '@aglyn/aglyn/foundation/definitions/contact.types'\nimport {\n contactCompanyMirrorValue,\n settleCompanyContactsCounts,\n} from './contact-company-link'\nimport {\n emailIndexBeside,\n findContactByEmail,\n writeContactEmailIndex,\n} from './contact-email-index'\nimport {\n consentGroupForSite,\n getOrgForHost,\n orgDataCollectionForHost,\n} from './organizations'\n\n/**\n * What an upsert did, for the callers that need to know (AGL-2602).\n *\n * The capture doors never look: a form submission or an order must succeed\n * whatever happened to the CRM record, which is why this function swallows\n * its own errors and why every existing caller `await`s it for its side\n * effect alone. An IMPORT is the caller that has to know, row by row —\n * \"created\" and \"merged\" are its two headline numbers, and a row the\n * audience band refused has to be handed back to the operator as a row\n * rather than becoming one more tick on a counter nobody reconciles against\n * a file. A verdict is returned rather than thrown so the swallowing stays:\n * `refused: 'error'` is the same silence the doors have always had, now with\n * a name.\n */\nexport type UpsertHostContactVerdict =\n | {\n contactId: string\n /** True when this call created the row; false when it merged into one. */\n created: boolean\n }\n | {\n /**\n * `erased`: the site holds an erasure row for the address (AGL-2623),\n * so no record is created — a capture must not quietly rebuild a\n * person the workspace erased. Only a CREATE is refused; a merge\n * cannot arise, because the erasure removed the row it would merge\n * into.\n */\n refused: 'invalid-email' | 'band' | 'erased' | 'error'\n }\n\n/**\n * The per-holder profile fields a door may write alongside the identity.\n *\n * `Pick`ed from the facet rather than typed afresh so the two cannot drift:\n * a field the facet grows is a field this option may carry the moment the\n * pick names it, and one it does not name is refused at compile time.\n */\nexport type UpsertHostContactFacet = Partial<\n Pick<\n ContactFacet,\n | 'phone'\n | 'jobTitle'\n | 'companyId'\n | 'address'\n | 'ownerUid'\n | 'lifecycleStage'\n | 'custom'\n >\n>\n\n/**\n * The profile fields a door may hand this function (AGL-2596): the parts of\n * a person's record that no capture surface collects — the console's create\n * drawer and the import do, and the order door adds the stage. `custom` is\n * the holder's own field values, keyed by `ContactFieldDefinition.key`; the\n * import maps spreadsheet columns onto them, and the definitions live under\n * the same group the values are written to.\n */\nexport type ContactProfileInput = UpsertHostContactFacet\n\n/**\n * The profile as it may be STORED: every value normalized, every unusable\n * one dropped, and nothing present that was not given.\n *\n * Only the keys given come back, which is what lets a merge write this\n * straight into the facet: a door that knows the phone number and nothing\n * else leaves the title, the owner and the stage exactly as another door\n * left them. An address given as `null` is a deliberate clearing and is kept\n * as `null`; one that normalizes to nothing is the same thing.\n */\nfunction storableProfile(input: ContactProfileInput | undefined): {\n phone?: string\n jobTitle?: string\n address?: ReturnType<typeof normalizeAddress>\n companyId?: string\n ownerUid?: string\n lifecycleStage?: ContactFacet['lifecycleStage']\n custom?: Record<string, ContactCustomValue>\n} {\n if (!input) return {}\n const out: ReturnType<typeof storableProfile> = {}\n if (input.phone !== undefined) {\n const phone = normalizePhone(input.phone)\n if (phone) out.phone = phone\n }\n if (typeof input.jobTitle === 'string') {\n const jobTitle = input.jobTitle.trim().slice(0, 120)\n if (jobTitle) out.jobTitle = jobTitle\n }\n if (input.address !== undefined) out.address = normalizeAddress(input.address)\n if (typeof input.companyId === 'string' && input.companyId.trim()) {\n out.companyId = input.companyId.trim().slice(0, 128)\n }\n if (typeof input.ownerUid === 'string' && input.ownerUid.trim()) {\n out.ownerUid = input.ownerUid.trim().slice(0, 128)\n }\n if (isContactLifecycleStage(input.lifecycleStage)) {\n out.lifecycleStage = input.lifecycleStage\n }\n if (input.custom && typeof input.custom === 'object') {\n /*\n * Only a key a field definition could have, and only a value the field\n * types can hold. A nested object here is a map the merge below would\n * write as a subtree nobody can render; a key with a dot in it would be\n * read as a PATH by the next dotted update to touch the facet. Nothing is\n * coerced — a door that has a number should send one — and an empty map\n * is left off rather than written as `{}` over a holder's values.\n */\n const custom: Record<string, ContactCustomValue> = {}\n for (const [key, value] of Object.entries(input.custom)) {\n if (!CONTACT_FIELD_KEY_PATTERN.test(key)) continue\n if (\n value === null ||\n typeof value === 'string' ||\n typeof value === 'number' ||\n typeof value === 'boolean'\n ) {\n custom[key] = typeof value === 'string' ? value.slice(0, 2000) : value\n }\n }\n if (Object.keys(custom).length) out.custom = custom\n }\n return out\n}\n\n/**\n * The org's companies collection, beside its contacts one.\n *\n * Reached through the contacts reference's parent — the org document —\n * because that is the one handle this function holds on the org. `parent`\n * is `null` only for a root collection, which an org subcollection never\n * is; answered as `null` rather than thrown so a count that has nowhere to\n * land is skipped, and never costs the capture.\n */\nfunction companiesBeside(\n contactsRef: FirebaseFirestore.CollectionReference,\n): FirebaseFirestore.CollectionReference | null {\n const parent = contactsRef.parent\n return parent ? parent.collection(CRM_COLLECTIONS.companies) : null\n}\n\n/**\n * Tags as the profile drawer stores them: trimmed, lowercased, deduplicated\n * and capped at twenty — so an imported `VIP` and a typed `vip` are one tag.\n */\nfunction normalizeTags(tags: readonly string[] | undefined): string[] {\n return [\n ...new Set(\n (tags ?? [])\n .map((tag) => String(tag ?? '').trim().toLowerCase())\n .filter(Boolean),\n ),\n ].slice(0, 20)\n}\n\n/**\n * Contacts ingestion (AGL-197): upserts an org-scoped contact doc (AGL-237)\n * keyed by normalized email from any capture point (forms, membership,\n * orders, bookings). Fire-and-forget by design — callers should never\n * fail their primary write because contact capture had a problem.\n *\n * Quota (AGL-890): contacts are audience BANDS, not hard caps. Paid plans\n * always create — contacts past the included band meter onto the monthly\n * invoice (report-usage cron). Free hard-bands at the included count:\n * only there do dropped creations increment `counters/contactsDropped`,\n * surfaced as a console alert (AGL-891). Interactions on existing\n * contacts always append regardless of plan.\n */\n/**\n * What a capture door learns when its capture made a NEW person.\n *\n * Handed to {@link UpsertHostContactOptions.onCreated} once, on the create\n * branch only. The merge branch is a visit by somebody the org already held,\n * which is another interaction and not a new contact — the same line\n * `campaignTouch` draws for attribution. Scalars and one string array, so\n * the runtime can flatten it into an event payload without inventing keys.\n */\nexport interface HostContactCreated {\n contactId: string\n hostId: string\n email: string\n name?: string\n source: ContactSource\n /** The capture surface's campaigns, normalized — `[]` when it had none. */\n campaignIds: string[]\n /**\n * The stage the create wrote onto the capturing facet — the door's\n * {@link UpsertHostContactOptions.initialLifecycleStage}, or the profile's\n * own — and absent when the capture named none, so an automation can\n * filter `lifecycleStage == \"lead\"` on the day the person appears.\n */\n lifecycleStage?: ContactLifecycleStage\n}\n\nexport interface UpsertHostContactOptions {\n hostId: string\n email: unknown\n name?: string\n source: ContactSource\n interaction: Omit<ContactInteraction, 'type' | 'atMs'> & { atMs?: number }\n /**\n * Explicit marketing opt-in (AGL-301) with a consent timestamp, recorded\n * against {@link hostId} — the brand whose form carried the checkbox.\n */\n marketingConsent?: boolean\n /**\n * An explicit REFUSAL, recorded against {@link hostId} (AGL-3185).\n *\n * Its own flag rather than `marketingConsent: false`, because every door\n * that passes a checkbox's value passes `false` for a box left alone, and\n * a box left alone records nothing — absence is the third state, and\n * turning it into a refusal would silently make every unticked visitor\n * unmailable. A door sets this only for an act of refusal: a switch turned\n * off, a prompt answered no. Ignored when {@link marketingConsent} is true.\n */\n declineMarketingConsent?: boolean\n /**\n * The provenance stored on the consent entry {@link marketingConsent} or\n * {@link declineMarketingConsent} writes (AGL-3185): who recorded it, which\n * door, which wording version. The console doors pass the person's own\n * (`actor: 'person'`); a site's capture surface passes none, which the\n * reader takes as the person's own act anyway.\n */\n marketingConsentSource?: MarketingConsentSource\n /**\n * Order value in cents — rolls into RFM fields (AGL-328).\n *\n * WHAT IT COUNTS (AGL-1748). GROSS of the platform fee and GROSS of\n * refunds — the money the customer handed over, not the money the merchant\n * kept. Every writer passes the same thing: whatever was actually charged\n * (`amount_total` for a Stripe path, `totals.totalCents` for POS), never a\n * figure re-derived from product docs, which is the AGL-1698/AGL-1711\n * lesson. Gross of the fee because this is a CUSTOMER attribute answering\n * \"what is this person worth to me?\", and the fee is a cost of the channel,\n * not something the buyer failed to spend.\n *\n * Refunds are still NOT netted here, and now they are recorded elsewhere\n * (AGL-1754). `refund.ts` writes `refundedCents`, `refundedOrdersCount` and\n * `lastRefundAtMs` BESIDE these fields — the shape AGL-1747 chose for the\n * same question on the orders CSV — rather than decrementing a stored number\n * whose meaning would then differ between rows written before and after that\n * commit. So `ltvCents` and `ordersCount` remain gross by definition, and a\n * READER that wants the net computes `ltvCents - refundedCents`, clamping\n * only what it ranks on: the difference can be negative for a customer whose\n * pre-AGL-1748 purchase was never counted and whose refund was, which is a\n * missing purchase showing itself rather than a corrupt contact. AGL-1753 is\n * the backfill that reconciles it. See `contact-refund.ts` in the commerce\n * plugin for the full reasoning and for why a refund never CREATES a contact.\n *\n * Passing 0 or omitting it means \"no purchase\": `ltvCents`, `ordersCount`,\n * `lastPurchaseAtMs` and `firstPurchaseAtMs` are all left untouched, which\n * is why a caller that formats the amount into the interaction summary and\n * forgets this field records a customer who has apparently never bought\n * anything.\n */\n purchaseCents?: number\n /**\n * The currency {@link purchaseCents} is in, lowercase, when the door knows.\n *\n * Absent everywhere today, because no order document carries a currency and\n * every checkout door writes `currency: 'usd'` onto the Stripe line items.\n * `attributeOrderToEmail` defaults it on that basis and says so. The field\n * exists so a door that ever charges in something else can pass it, and the\n * campaign revenue report keeps it in its own bucket rather than adding it\n * to the dollars.\n */\n purchaseCurrency?: string\n /**\n * The campaign this person came from, already resolved by the door.\n *\n * ⛔ The ORDER path passes none, and must not start. An order already has\n * its own join one branch below — `attributeOrderToEmail`, keyed on the\n * order id — and a second record for the same sale would be the same money\n * counted twice under two rules. This is the door for the moments an order\n * does NOT cover: a form submission, a membership sign-up, a booking, a\n * newsletter capture.\n *\n * Resolved rather than raw, for the reason `addHostLead` states: one\n * visitor action reaches several writers and the touch lookup is paid once.\n */\n campaignTouch?: ResolvedCampaignTouch | null\n /**\n * The campaigns the CAPTURE SURFACE is filed under.\n *\n * ⚠️ A different fact from {@link campaignTouch} beside it, and the two must\n * never be folded together. A touch is where the visitor came FROM — an ad,\n * a link, a browser-supplied label resolved through an allowlist. This is\n * which campaigns the merchant put the form itself in, which is the\n * merchant's own act and is true of everybody who fills that form in,\n * including the visitor who arrived by typing the address.\n *\n * ⛔ And it is not consent. Filing a form under a campaign says nothing\n * about what the person agreed to; `marketingConsent` above is the only\n * input that records a basis.\n */\n campaignIds?: readonly string[]\n /**\n * Tags to put on THIS holder's facet (AGL-2602).\n *\n * Added to, never replaced, on a merge: a person the merchant tagged by\n * hand and later imported keeps the hand-written tag beside the file's.\n */\n tags?: readonly string[]\n /**\n * The profile a door knows about the person — phone, title, company,\n * address, owner, stage, custom values — written into THIS holder's facet\n * (AGL-2602). Only the keys present are written, so a door that knows the\n * phone and nothing else does not blank the title somebody typed.\n */\n facet?: UpsertHostContactFacet\n /**\n * The EARLIEST stage that describes what this capture was (AGL-2612).\n *\n * Every door names one: a form submission is a `lead`, a newsletter opt-in\n * or a member sign-up is a `subscriber`, a purchase — an order, a paid\n * booking — is a `customer`. The rule applied to it is\n * `advanceContactLifecycleStage`: it fills an empty stage and advances an\n * earlier one, and never moves anybody back, so a customer who fills in\n * the contact form is still a customer and a subscriber who then submits\n * a form becomes a lead. Applied on top of `facet.lifecycleStage` when a\n * door carries both — the caller's stage is the base and this is its\n * floor.\n *\n * A FLOOR and not a value, which is why it is not the facet field: the\n * facet's `lifecycleStage` is a SET, the shape the console's create\n * drawer and the import need — the merchant typed a stage and that is the\n * stage — and a capture door writing a set would put every returning\n * customer back to `lead` on their next enquiry. Omitted by the doors\n * that carry the caller's own stage or none (manual, import, API, a lead\n * conversion), whose contacts read as \"no stage\", which is true.\n */\n initialLifecycleStage?: ContactLifecycleStage\n /**\n * Told when this capture created a contact (AGL-2605).\n *\n * A HOOK rather than an event emitted from here, and the reason is the\n * dependency direction: the event fan-out lives in `libs/tenant/runtime`,\n * which imports THIS library for its Firestore handle and its org helpers.\n * An import back up from here would be a cycle, and the module boundaries\n * (`scope:data` may depend on data and util only) refuse it besides. So\n * this module reports the fact and the runtime's `captureHostContact`\n * turns it into `contactCreated` — every server door goes through that\n * wrapper, and a door that calls this function directly has chosen to\n * create contacts nothing can react to.\n *\n * Awaited with its own catch, like the order join above: the least\n * important write on the path, and a failure in it must not cost the\n * capture that already happened.\n */\n onCreated?: (created: HostContactCreated) => void | Promise<void>\n}\n\nexport async function upsertHostContact(\n options: UpsertHostContactOptions,\n): Promise<UpsertHostContactVerdict> {\n try {\n const email = normalizeContactEmail(options.email)\n if (!email) return { refused: 'invalid-email' }\n const tags = normalizeTags(options.tags)\n\n /*==========================================\n * THE PURCHASE DOOR, AND THEREFORE THE ATTRIBUTION DOOR.\n *\n * Every way of buying something in this product — the cart, buy-now, the\n * POS register, a draft order, a reservation, a subscription renewal, a\n * booking — announces itself here, in exactly one shape: source `order`,\n * a `purchaseCents` amount, and a `refId` naming what was bought. That\n * shape IS the purchase chokepoint, which is why the revenue join hangs\n * off it rather than off seven call sites in a webhook.\n *\n * ABOVE the audience-band gate below, and deliberately. Contact creation\n * is band gated, so a Free org past its included count drops the CRM\n * record — and an attribution written inside that branch would drop the\n * revenue with it. The join keys on the address hash, exactly as the\n * touch and the suppression list do, so it never needs a contact document\n * to exist: a guest checkout by somebody who is not and never becomes a\n * contact still credits the campaign whose link they clicked.\n *\n * Its own `catch`, inside a function that already swallows: this is the\n * least important write on the path, and a failure here must not cost the\n * contact capture below it.\n *=========================================*/\n if (options.source === 'order' && options.interaction.refId) {\n await attributeOrderToEmail({\n hostId: options.hostId,\n orderId: String(options.interaction.refId),\n email,\n amountCents: Number(options.purchaseCents ?? 0),\n ...(options.purchaseCurrency\n ? { currency: options.purchaseCurrency }\n : {}),\n orderedAtMs: options.interaction.atMs ?? Date.now(),\n }).catch(() => null)\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(options.hostId)\n // Contacts are org-scoped (AGL-237): every host in the org feeds one\n // shared list.\n const contactsRef = await orgDataCollectionForHost(\n options.hostId,\n 'contacts',\n )\n /*\n * The consent group this capture belongs to — the sites declared to be\n * one sender, or this site alone. Resolved once and used for three\n * different decisions below, which must all agree: which controller the\n * basis is recorded for, which sites the row becomes visible to, and\n * whether the capture surface had to disclose anything.\n */\n const group = await consentGroupForSite(options.hostId)\n /*\n * THE CONSENT ENTRY THIS CAPTURE WRITES, decided once for both branches.\n *\n * A grant outranks a refusal flag set beside it — one door cannot mean\n * both — and neither writes anything when the door carried no act, so\n * an unticked box stays the third state. The provenance rides inside the\n * per-host entry, where the reader looks for it, never at the top of a\n * document every brand in the org shares.\n */\n const consentExtra = options.marketingConsentSource\n ? { [MARKETING_CONSENT_SOURCE_FIELD]: options.marketingConsentSource }\n : undefined\n const consentFields = options.marketingConsent\n ? marketingConsentFieldsForGroup(group, Date.now(), consentExtra)\n : options.declineMarketingConsent\n ? declineMarketingConsentFields(options.hostId, Date.now(), consentExtra)\n : {}\n const interaction: ContactInteraction = {\n type: options.source,\n atMs: options.interaction.atMs ?? Date.now(),\n // WHICH SITE this visit happened on. The row is shared; the history on\n // it is not, and a timeline with no site cannot be split for an\n // agency's client without showing them another client's activity.\n hostId: options.hostId,\n ...(options.interaction.refId\n ? { refId: options.interaction.refId }\n : {}),\n ...(options.interaction.summary\n ? { summary: options.interaction.summary.slice(0, 200) }\n : {}),\n // The entry point, when the door knows it. Written only when present:\n // an absent field is a door that has none, and Firestore rejects\n // `undefined` inside an array element outright.\n ...(options.interaction.formId\n ? { formId: String(options.interaction.formId).slice(0, 128) }\n : {}),\n ...(options.interaction.path\n ? { path: String(options.interaction.path).slice(0, 500) }\n : {}),\n }\n\n /*\n * THE CAMPAIGNS THIS CAPTURE FILES THE PERSON UNDER.\n *\n * Normalized here rather than trusted, because it reaches this function\n * from a public endpoint's document read and every reader of the stored\n * array goes through the same coercion.\n */\n const campaignIds = normalizeCampaignIds(options.campaignIds ?? [])\n /*\n * THE CUSTOM FIELD VALUES THIS CAPTURE CARRIES, as one nested map.\n *\n * Only the keys the door resolved. Written in the NESTED form because both\n * writes below are merge-sets, which deep-merge a map one key at a time:\n * `custom: { tier: 'Gold' }` lands beside an existing `custom.vip` and\n * leaves it standing. A dotted `facets.h1.custom.tier` path would be a\n * literal field name to a `set`, and a `custom` written whole would take\n * every other key with it.\n */\n const customEntries = Object.entries(options.facet?.custom ?? {})\n const customFacet = customEntries.length\n ? { custom: Object.fromEntries(customEntries) }\n : {}\n\n /*==========================================\n * THE DEDUPE LOOKUP IS UNSCOPED, AND HAS TO BE.\n *\n * One human who touched two sites is ONE person. Narrowing this read to\n * what the capturing site may already see would make a second submission\n * on a sibling brand create a SECOND document for the same address —\n * which loses the dedupe the shared address book exists for, and bills\n * the org twice for one human.\n *\n * Recognizing somebody is not the same act as being allowed to read their\n * row, and the two were the same query while every contact was stamped\n * org-wide. They are separated here: this finds the person, and\n * `visibleTo` below decides who may see them — widened by the capture\n * that just happened, never by the lookup that found them.\n *\n * THROUGH THE ADDRESS INDEX FIRST (AGL-2625). A person whose two records\n * were merged answers to two addresses, and only one of them is the\n * document's `email`; the index is what lets a capture on the other one\n * land on the survivor instead of minting the duplicate again. The query\n * stays as the fallback, so a row the index has not seen costs one extra\n * read once and needs no backfill.\n *=========================================*/\n const docSnapshot = await findContactByEmail(contactsRef, email)\n\n if (docSnapshot) {\n /*\n * MERGED INTO THIS GROUP'S FACET, not into the top of the document.\n *\n * `sources`, `interactions`, the tags, the notes and every commercial\n * figure are the HOLDER's own business records: a booking taken by one\n * client of an agency is that client's, and while these lived at the\n * top of a shared row every other client could read them. The identity\n * — the address, and a canonical name for a holder that has set none of\n * its own — stays shared, because that is what makes this one row.\n */\n const facet = readContactFacet(\n docSnapshot.data() as Record<string, unknown>,\n group.groupId,\n )\n const merged = mergeContactInteraction(\n {\n name: facet.name ?? undefined,\n sources: facet.sources,\n interactions: facet.interactions,\n },\n { source: options.source, interaction, name: options.name },\n )\n /*\n * THE PROFILE, as this door knows it (AGL-2596).\n *\n * Given keys only, so the merge below leaves untouched whatever another\n * door wrote. The stage is the one field with a rule of its own: the\n * door's `initialLifecycleStage` fills an empty stage and advances an\n * earlier one, and never moves anybody back —\n * `advanceContactLifecycleStage` is that rule, applied to the stage\n * this door asked for outright or, failing that, the one already\n * stored. Written only when it comes back with something: a door that\n * named no stage and found none leaves the key absent, which reads as\n * \"no stage\" rather than as a stage somebody picked.\n */\n const profile = storableProfile(options.facet)\n const advanced = advanceContactLifecycleStage(\n profile.lifecycleStage ?? facet.lifecycleStage,\n options.initialLifecycleStage,\n )\n if (advanced) profile.lifecycleStage = advanced\n /*\n * THE FORM MIRROR, bounded (see `HostContact.formIds`).\n *\n * Read off the document the lookup already fetched, so the bound costs\n * no extra read: a person who has come in through twenty forms keeps\n * the twenty, and this capture's form stays on the interaction alone.\n * `arrayUnion` for the add, so a concurrent capture on a sibling site\n * cannot drop this one's id.\n */\n const heldFormIds: unknown[] = Array.isArray(\n docSnapshot.get(CONTACT_FORM_IDS_FIELD),\n )\n ? docSnapshot.get(CONTACT_FORM_IDS_FIELD)\n : []\n const formIdToAdd =\n interaction.formId &&\n !heldFormIds.includes(interaction.formId) &&\n heldFormIds.length < CONTACT_FORM_IDS_CAP\n ? interaction.formId\n : null\n /*\n * THE COMPANY LINK, when the door carried one (AGL-2613).\n *\n * The facet's `companyId` is written with the rest of the profile\n * below, but the facet is not the whole association: the top-level\n * `companyIds` mirror is what the company page queries, and the\n * company's `contactsCount` is what the companies list shows. The\n * planner reads the row as it stands — this holder's previous link,\n * the mirror, the other holders' ids — and says what the mirror\n * becomes and which counts move; the mirror change rides in this same\n * merge-set, and the counts settle after it.\n */\n const link =\n profile.companyId !== undefined\n ? planContactCompanyLink(\n readContactCompanyLink(\n docSnapshot.data() as Record<string, unknown>,\n group.groupId,\n ),\n profile.companyId,\n )\n : null\n const mirror = link ? contactCompanyMirrorValue(link) : undefined\n await docSnapshot.ref.set(\n {\n // The search keys travel WITH the name, and only when the name is\n // written: stamping an empty key over a real one would make the\n // contact unfindable by the name it still displays.\n ...(merged.name ? nameSearchFields(merged.name) : {}),\n // The search echo of the facet's phone — see `HostContact.phone`.\n ...(profile.phone ? { phone: profile.phone } : {}),\n ...(mirror !== undefined ? { [CONTACT_COMPANY_IDS_FIELD]: mirror } : {}),\n /*\n * NESTED, not dot-pathed. This is a `set(…, { merge: true })`, and\n * a merge-set treats a key containing dots as a literal field name\n * — only `update()` reads them as paths. The nested form is what\n * Firestore deep-merges, so this writes one holder's facet and\n * leaves every other holder's untouched.\n */\n [CONTACT_FACETS_FIELD]: {\n [group.groupId]: {\n sources: merged.sources,\n interactions: merged.interactions,\n ...(merged.name ? { name: merged.name } : {}),\n /*\n * ADDED TO, never replaced. A person who filled in the spring\n * form and later the summer one is in both pushes, and an\n * assignment that overwrote would take a campaign a merchant\n * filed them under back out with nothing on screen to say so —\n * the reason `campaign-membership.ts` made the field an array\n * and the automation step has always used `arrayUnion`.\n *\n * Nested under the group id rather than written at\n * `contactCampaignFieldPath`, because this is a merge-`set`: a\n * `set` treats a dotted string as a literal field NAME and would\n * mint a top-level key with dots in it. Only `update()` reads\n * dots as a path. The nested form is what Firestore deep-merges,\n * so it reaches one holder's facet and leaves every other\n * holder's alone — the same guarantee the dotted path gives the\n * automation step, in the shape this write is allowed to take.\n */\n ...(campaignIds.length\n ? { campaignIds: FieldValue.arrayUnion(...campaignIds) }\n : {}),\n // The same union rule as the campaigns above it, for the same\n // reason: a tag the merchant put on by hand survives the file.\n ...(tags.length ? { tags: FieldValue.arrayUnion(...tags) } : {}),\n // Present keys only, deep-merged by the merge-set — so the\n // fields this door did not carry keep whatever they held.\n ...profile,\n ...(options.purchaseCents\n ? {\n ltvCents: FieldValue.increment(options.purchaseCents),\n ordersCount: FieldValue.increment(1),\n lastPurchaseAtMs: Date.now(),\n // A contact that EXISTED before their first purchase\n // reached this branch, and it never wrote\n // `firstPurchaseAtMs` — only the create path did. So\n // every converted lead permanently lacked RFM's R anchor\n // while walk-in buyers carried it. Set it on the first\n // purchase only; later purchases must not move it.\n ...(facet.firstPurchaseAtMs\n ? {}\n : { firstPurchaseAtMs: Date.now() }),\n }\n : {}),\n },\n },\n /*\n * ATTRIBUTION GROWS ON THE MERGE BRANCH, which the create-only\n * `hostId` beside it never did — so a person the first site\n * captured and the second site later met read as the first site's\n * alone, forever.\n *\n * `arrayUnion`, so the audience filter \"everyone captured on A, B\n * or C\" answers with the sites that actually met this person.\n */\n [CAPTURED_BY_HOST_FIELD]: FieldValue.arrayUnion(options.hostId),\n ...(formIdToAdd\n ? { [CONTACT_FORM_IDS_FIELD]: FieldValue.arrayUnion(formIdToAdd) }\n : {}),\n /*\n * AND SO DOES VISIBILITY — by the capture, never by the lookup.\n *\n * This site just collected this person: it has its own relationship\n * with them and may see the row. A site that has never captured\n * them gains nothing here, which is what keeps an agency's clients\n * apart on a document all of them share. The per-interaction\n * `hostId` above is what keeps the HISTORY apart on the same row.\n */\n visibleTo: FieldValue.arrayUnion(...consentGroupScope(group)),\n /*\n * RECORDED AGAINST THE CAPTURING SITE, not against the org.\n *\n * The contact document is shared by every site in the org, which is\n * the point of it — one address book behind many brands. Its\n * consent is not shared on the same terms: the checkbox this\n * capture carried was ticked under one brand's name, on one brand's\n * form, and a basis written at the top of this document made the\n * person mailable by every other brand the account holds.\n *\n * A merge writes one key of the map and leaves the rest, so a\n * person who opts in to a second site accumulates two grants rather\n * than replacing the first.\n */\n ...consentFields,\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n await settleCompanyContactsCounts(companiesBeside(contactsRef), link)\n return { contactId: docSnapshot.id, created: false }\n }\n\n /*\n * THE ERASED PERSON DOES NOT COME BACK (AGL-2623).\n *\n * Before the band, on the CREATE branch only — the one read this adds\n * is paid by a capture that would otherwise make a new row, never by\n * the merge above. An erasure wrote a row on this site's suppression\n * list with no address on it; finding that row here is what keeps the\n * next form fill or checkout from rebuilding the record the workspace\n * just erased. The order or booking that triggered the capture still\n * succeeds, exactly as it does when the band refuses: the person bought\n * something, and the merchant's record of the sale is not the CRM's\n * record of the person.\n */\n if (await hostRefusesCaptureForErasure(options.hostId, email, firestore)) {\n return { refused: 'erased' }\n }\n\n // New contact: records-band check via the aggregate counts (cheap; no\n // doc reads) against the owning org's entitlements (AGL-238/890).\n // Metered plans always pass (overage bills via the report-usage\n // cron); only free's hard band drops the CRM record — visibly, via\n // the counter and the console alert (AGL-891). The signup/order that\n // triggered the capture always succeeds either way.\n //\n // THE RECORDS BAND, not the contacts headcount (AGL-2611): companies\n // and deals share this band, so a capture is refused on a Free org\n // whose hundred records are ninety companies and ten people — the same\n // verdict the company drawer would give the ninety-first company.\n // `contactsRef` is handed in so the contacts aggregate is the one this\n // door already reads; the org reference is its parent.\n const orgBilling = await getOrgForHost(options.hostId)\n const orgRef =\n contactsRef.parent ??\n firestore.collection('orgs').doc(String(orgBilling?.orgId ?? ''))\n const { crmRecordsCount } = await countCrmRecords(orgRef, contactsRef)\n const quota = checkCrmRecordsQuota(\n (orgBilling?.org as any) ?? null,\n crmRecordsCount,\n )\n if (!quota.allowed) {\n await hostRef\n .collection('counters')\n .doc('contactsDropped')\n .set({ total: FieldValue.increment(1) }, { merge: true })\n return { refused: 'band' }\n }\n\n /*\n * The profile on a create, with the door's stage floor applied the same\n * way as on a merge — there is no stored stage yet, so the floor is what\n * a door that named one writes. A `null` address is dropped rather than\n * written: there is nothing on a new document for it to clear.\n */\n const profile = storableProfile(options.facet)\n const advanced = advanceContactLifecycleStage(\n profile.lifecycleStage,\n options.initialLifecycleStage,\n )\n if (advanced) profile.lifecycleStage = advanced\n if (profile.address === null) delete profile.address\n\n const created = await contactsRef.add({\n hostId: options.hostId,\n /*\n * WHICH SITES HAVE MET THIS PERSON — attribution, and separate from\n * consent above.\n *\n * The scalar `hostId` beside it names the FIRST capturing site and is\n * never rewritten, which is provenance for the ROW. This array is the\n * one that grows and the one an audience query can filter on.\n */\n [CAPTURED_BY_HOST_FIELD]: [options.hostId],\n // The form mirror's first entry, when this capture came through one —\n // see `HostContact.formIds`. Absent otherwise, like the campaigns.\n ...(interaction.formId\n ? { [CONTACT_FORM_IDS_FIELD]: [interaction.formId] }\n : {}),\n /*\n * THE CAPTURING GROUP, not the whole org.\n *\n * Stamping `['org']` here made every contact readable by every site in\n * the account on the day it was created — so an agency's twelve clients\n * shared one address book by default, and closing the missing-field\n * fail-open would not have touched it, because the field was present\n * and said so.\n *\n * A group of one — the default — is this site alone. A declared group\n * is the sites that already present as one sender. Widening beyond that\n * is available and is an ACT: an org may set `defaultResourceScope` to\n * `'org'`, or a later capture on a sibling site unions that site in.\n */\n visibleTo:\n (orgBilling?.org as { defaultResourceScope?: 'org' | 'host' } | null)\n ?.defaultResourceScope === 'org'\n ? [ORG_SCOPE_TOKEN]\n : consentGroupScope(group),\n email,\n ...(options.name ? nameSearchFields(options.name.slice(0, 120)) : {}),\n // The search echo of the facet's phone — see `HostContact.phone`.\n ...(profile.phone ? { phone: profile.phone } : {}),\n // The company mirror the company page queries (AGL-2613) — a create\n // has nothing to union with, so the door's company is the whole list.\n ...(profile.companyId\n ? { [CONTACT_COMPANY_IDS_FIELD]: [profile.companyId] }\n : {}),\n // The facet this capture creates. Everything a holder owns lives under\n // its own group id; the address and the canonical name above are the\n // only shared identity.\n [CONTACT_FACETS_FIELD]: {\n [group.groupId]: {\n sources: { [options.source]: true },\n interactions: [interaction],\n tags,\n // A create has nothing to union with, so the normalized list is the\n // whole membership.\n ...(campaignIds.length ? { campaignIds } : {}),\n ...customFacet,\n ...(options.name\n ? { name: options.name.slice(0, 120) }\n : {}),\n ...profile,\n ...(options.purchaseCents\n ? {\n ltvCents: options.purchaseCents,\n ordersCount: 1,\n lastPurchaseAtMs: Date.now(),\n firstPurchaseAtMs: Date.now(),\n }\n : {}),\n },\n },\n ...consentFields,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n // The address now resolves to this row (AGL-2625). Its own catch inside\n // the writer, so an index the capture could not reach costs nothing.\n await writeContactEmailIndex(emailIndexBeside(contactsRef), created.id, [email])\n // The company the door named now has one more contact naming it\n // (AGL-2613) — the fresh row's plan is the trivial one, nothing held\n // before and nothing held elsewhere.\n if (profile.companyId) {\n await settleCompanyContactsCounts(\n companiesBeside(contactsRef),\n planContactCompanyLink(\n { companyId: null, companyIds: [], heldElsewhere: [] },\n profile.companyId,\n ),\n )\n }\n /*\n * ATTRIBUTED ON CREATION ONLY, and therefore below the band gate rather\n * than above it — the opposite placement to the order join at the top of\n * this function, deliberately.\n *\n * The order join sits above because it credits MONEY, which is real\n * whether or not a CRM record was kept for the buyer, and a Free org past\n * its included band would otherwise silently drop the revenue with the\n * contact. This credits a contact, which does not exist when the band\n * dropped it: attributing one above the gate would report a campaign as\n * having produced people the customer cannot see anywhere in the console.\n *\n * The existing-contact branch above returns without reaching here, which\n * is the same rule `addHostLead` applies — an interaction appended to\n * somebody the site already held is another visit, not a new person, and\n * crediting it would let the most recent campaign re-earn the whole list.\n */\n if (options.campaignTouch) {\n await attributeCampaignConversion({\n hostId: options.hostId,\n kind: 'contact',\n refId: created.id,\n touch: options.campaignTouch,\n convertedAtMs: interaction.atMs,\n })\n }\n /*\n * REPORTED ON CREATION ONLY, below the band gate for the reason the\n * attribution above is: a contact the band dropped does not exist, and\n * an automation told about it would act on a person the console cannot\n * show. The merge branch returned long before this line, so a repeat\n * visit by somebody already held is never announced as a new contact.\n */\n if (options.onCreated) {\n await Promise.resolve(\n options.onCreated({\n contactId: created.id,\n hostId: options.hostId,\n email,\n ...(options.name ? { name: options.name.slice(0, 120) } : {}),\n source: options.source,\n campaignIds,\n ...(profile.lifecycleStage\n ? { lifecycleStage: profile.lifecycleStage }\n : {}),\n }),\n ).catch((error) => {\n console.error('upsertHostContact onCreated failed', error)\n })\n }\n return { contactId: created.id, created: true }\n } catch (error) {\n console.error('upsertHostContact failed', error)\n return { refused: 'error' }\n }\n}\n"],"names":["CAPTURED_BY_HOST_FIELD","checkCrmRecordsQuota","consentGroupScope","CONTACT_FACETS_FIELD","CONTACT_FORM_IDS_CAP","CONTACT_FORM_IDS_FIELD","marketingConsentFieldsForGroup","mergeContactInteraction","normalizeCampaignIds","readContactFacet","normalizeContactEmail","ORG_SCOPE_TOKEN","FieldValue","firebaseAdmin","countCrmRecords","attributeOrderToEmail","hostRefusesCaptureForErasure","attributeCampaignConversion","nameSearchFields","declineMarketingConsentFields","MARKETING_CONSENT_SOURCE_FIELD","advanceContactLifecycleStage","CONTACT_COMPANY_IDS_FIELD","CONTACT_FIELD_KEY_PATTERN","CRM_COLLECTIONS","isContactLifecycleStage","planContactCompanyLink","readContactCompanyLink","normalizeAddress","normalizePhone","contactCompanyMirrorValue","settleCompanyContactsCounts","emailIndexBeside","findContactByEmail","writeContactEmailIndex","consentGroupForSite","getOrgForHost","orgDataCollectionForHost","storableProfile","input","out","phone","undefined","jobTitle","trim","slice","address","companyId","ownerUid","lifecycleStage","custom","key","value","Object","entries","test","keys","length","companiesBeside","contactsRef","parent","collection","companies","normalizeTags","tags","Set","map","tag","String","toLowerCase","filter","Boolean","upsertHostContact","options","orgBilling","email","refused","source","interaction","refId","hostId","orderId","amountCents","Number","purchaseCents","purchaseCurrency","currency","orderedAtMs","atMs","Date","now","catch","firestore","app","hostRef","doc","group","consentExtra","marketingConsentSource","consentFields","marketingConsent","declineMarketingConsent","type","summary","formId","path","campaignIds","customEntries","facet","customFacet","fromEntries","docSnapshot","profile","data","groupId","merged","name","sources","interactions","advanced","initialLifecycleStage","heldFormIds","Array","isArray","get","formIdToAdd","includes","link","mirror","ref","set","arrayUnion","ltvCents","increment","ordersCount","lastPurchaseAtMs","firstPurchaseAtMs","visibleTo","updatedAt","serverTimestamp","merge","contactId","id","created","orgRef","orgId","crmRecordsCount","quota","org","allowed","total","add","defaultResourceScope","createdAt","companyIds","heldElsewhere","campaignTouch","kind","touch","convertedAtMs","onCreated","Promise","resolve","error","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/upsert-contact.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 type AglynPostalAddress,\n CAPTURED_BY_HOST_FIELD,\n checkCrmRecordsQuota,\n consentGroupScope,\n CONTACT_FACETS_FIELD,\n CONTACT_FORM_IDS_CAP,\n CONTACT_FORM_IDS_FIELD,\n type ContactFacet,\n type ContactInteraction,\n type ContactLifecycleStage,\n type ContactSource,\n marketingConsentFieldsForGroup,\n mergeContactInteraction,\n normalizeCampaignIds,\n readContactFacet,\n normalizeContactEmail,\n ORG_SCOPE_TOKEN,\n} from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { firebaseAdmin } from './firebase-admin'\nimport { countCrmRecords } from './crm-records'\nimport { attributeOrderToEmail } from './email-revenue-attribution'\nimport { hostRefusesCaptureForErasure } from './email-suppression'\nimport {\n attributeCampaignConversion,\n type ResolvedCampaignTouch,\n} from './campaign-conversion-attribution'\nimport { nameSearchFields } from '@aglyn/aglyn/app-utils/name-search'\nimport {\n declineMarketingConsentFields,\n MARKETING_CONSENT_SOURCE_FIELD,\n type MarketingConsentSource,\n} from '@aglyn/aglyn/app-utils/marketing-consent'\n/*\n * The module paths, like `name-search` above, rather than the barrel: the\n * pure helpers this door leans on are exactly the ones a spec of the door\n * substitutes a fixture barrel for, and a fixture that has to re-export the\n * whole of `@aglyn/aglyn` to keep a normalizer reachable is a fixture that\n * drifts. A direct path is real in every harness.\n */\nimport {\n advanceContactLifecycleStage,\n CONTACT_COMPANY_IDS_FIELD,\n CONTACT_FIELD_KEY_PATTERN,\n type ContactCustomValue,\n CRM_COLLECTIONS,\n isContactLifecycleStage,\n planContactCompanyLink,\n readContactCompanyLink,\n} from '@aglyn/aglyn/app-utils/crm'\nimport {\n normalizeAddress,\n normalizePhone,\n} from '@aglyn/aglyn/foundation/definitions/contact.types'\nimport {\n contactCompanyMirrorValue,\n settleCompanyContactsCounts,\n} from './contact-company-link'\nimport {\n emailIndexBeside,\n findContactByEmail,\n writeContactEmailIndex,\n} from './contact-email-index'\nimport {\n consentGroupForSite,\n getOrgForHost,\n orgDataCollectionForHost,\n} from './organizations'\n\n/**\n * What an upsert did, for the callers that need to know (AGL-2602).\n *\n * The capture doors never look: a form submission or an order must succeed\n * whatever happened to the CRM record, which is why this function swallows\n * its own errors and why every existing caller `await`s it for its side\n * effect alone. An IMPORT is the caller that has to know, row by row —\n * \"created\" and \"merged\" are its two headline numbers, and a row the\n * audience band refused has to be handed back to the operator as a row\n * rather than becoming one more tick on a counter nobody reconciles against\n * a file. A verdict is returned rather than thrown so the swallowing stays:\n * `refused: 'error'` is the same silence the doors have always had, now with\n * a name.\n */\nexport type UpsertHostContactVerdict =\n | {\n contactId: string\n /** True when this call created the row; false when it merged into one. */\n created: boolean\n }\n | {\n /**\n * `erased`: the site holds an erasure row for the address (AGL-2623),\n * so no record is created — a capture must not quietly rebuild a\n * person the workspace erased. Only a CREATE is refused; a merge\n * cannot arise, because the erasure removed the row it would merge\n * into.\n */\n refused: 'invalid-email' | 'band' | 'erased' | 'error'\n }\n\n/**\n * The per-holder profile fields a door may write alongside the identity.\n *\n * `Pick`ed from the facet rather than typed afresh so the two cannot drift:\n * a field the facet grows is a field this option may carry the moment the\n * pick names it, and one it does not name is refused at compile time.\n */\nexport type UpsertHostContactFacet = Partial<\n Pick<\n ContactFacet,\n | 'phone'\n | 'jobTitle'\n | 'companyId'\n | 'companyName'\n | 'address'\n | 'ownerUid'\n | 'lifecycleStage'\n | 'custom'\n >\n>\n\n/**\n * The profile fields a door may hand this function (AGL-2596): the parts of\n * a person's record that no capture surface collects — the console's create\n * drawer and the import do, and the order door adds the stage. `custom` is\n * the holder's own field values, keyed by `ContactFieldDefinition.key`; the\n * import maps spreadsheet columns onto them, and the definitions live under\n * the same group the values are written to.\n */\nexport type ContactProfileInput = UpsertHostContactFacet\n\n/**\n * The profile as it may be STORED: every value normalized, every unusable\n * one dropped, and nothing present that was not given.\n *\n * Only the keys given come back, which is what lets a merge write this\n * straight into the facet: a door that knows the phone number and nothing\n * else leaves the title, the owner and the stage exactly as another door\n * left them. An address given as `null` is a deliberate clearing and is kept\n * as `null`; one that normalizes to nothing is the same thing.\n */\nfunction storableProfile(input: ContactProfileInput | undefined): {\n phone?: string\n jobTitle?: string\n address?: ReturnType<typeof normalizeAddress>\n companyId?: string\n companyName?: string\n ownerUid?: string\n lifecycleStage?: ContactFacet['lifecycleStage']\n custom?: Record<string, ContactCustomValue>\n} {\n if (!input) return {}\n const out: ReturnType<typeof storableProfile> = {}\n if (input.phone !== undefined) {\n const phone = normalizePhone(input.phone)\n if (phone) out.phone = phone\n }\n if (typeof input.jobTitle === 'string') {\n const jobTitle = input.jobTitle.trim().slice(0, 120)\n if (jobTitle) out.jobTitle = jobTitle\n }\n if (input.address !== undefined) out.address = normalizeAddress(input.address)\n if (typeof input.companyId === 'string' && input.companyId.trim()) {\n out.companyId = input.companyId.trim().slice(0, 128)\n }\n // The name as this holder knows it, beside the link — the merge fields\n // read `facet.companyName`, and a link with no name renders as nothing.\n if (typeof input.companyName === 'string' && input.companyName.trim()) {\n out.companyName = input.companyName.trim().slice(0, 120)\n }\n if (typeof input.ownerUid === 'string' && input.ownerUid.trim()) {\n out.ownerUid = input.ownerUid.trim().slice(0, 128)\n }\n if (isContactLifecycleStage(input.lifecycleStage)) {\n out.lifecycleStage = input.lifecycleStage\n }\n if (input.custom && typeof input.custom === 'object') {\n /*\n * Only a key a field definition could have, and only a value the field\n * types can hold. A nested object here is a map the merge below would\n * write as a subtree nobody can render; a key with a dot in it would be\n * read as a PATH by the next dotted update to touch the facet. Nothing is\n * coerced — a door that has a number should send one — and an empty map\n * is left off rather than written as `{}` over a holder's values.\n */\n const custom: Record<string, ContactCustomValue> = {}\n for (const [key, value] of Object.entries(input.custom)) {\n if (!CONTACT_FIELD_KEY_PATTERN.test(key)) continue\n if (\n value === null ||\n typeof value === 'string' ||\n typeof value === 'number' ||\n typeof value === 'boolean'\n ) {\n custom[key] = typeof value === 'string' ? value.slice(0, 2000) : value\n }\n }\n if (Object.keys(custom).length) out.custom = custom\n }\n return out\n}\n\n/**\n * The org's companies collection, beside its contacts one.\n *\n * Reached through the contacts reference's parent — the org document —\n * because that is the one handle this function holds on the org. `parent`\n * is `null` only for a root collection, which an org subcollection never\n * is; answered as `null` rather than thrown so a count that has nowhere to\n * land is skipped, and never costs the capture.\n */\nfunction companiesBeside(\n contactsRef: FirebaseFirestore.CollectionReference,\n): FirebaseFirestore.CollectionReference | null {\n const parent = contactsRef.parent\n return parent ? parent.collection(CRM_COLLECTIONS.companies) : null\n}\n\n/**\n * Tags as the profile drawer stores them: trimmed, lowercased, deduplicated\n * and capped at twenty — so an imported `VIP` and a typed `vip` are one tag.\n */\nfunction normalizeTags(tags: readonly string[] | undefined): string[] {\n return [\n ...new Set(\n (tags ?? [])\n .map((tag) => String(tag ?? '').trim().toLowerCase())\n .filter(Boolean),\n ),\n ].slice(0, 20)\n}\n\n/**\n * Contacts ingestion (AGL-197): upserts an org-scoped contact doc (AGL-237)\n * keyed by normalized email from any capture point (forms, membership,\n * orders, bookings). Fire-and-forget by design — callers should never\n * fail their primary write because contact capture had a problem.\n *\n * Quota (AGL-890): contacts are audience BANDS, not hard caps. Paid plans\n * always create — contacts past the included band meter onto the monthly\n * invoice (report-usage cron). Free hard-bands at the included count:\n * only there do dropped creations increment `counters/contactsDropped`,\n * surfaced as a console alert (AGL-891). Interactions on existing\n * contacts always append regardless of plan.\n */\n/**\n * What a capture door learns when its capture made a NEW person.\n *\n * Handed to {@link UpsertHostContactOptions.onCreated} once, on the create\n * branch only. The merge branch is a visit by somebody the org already held,\n * which is another interaction and not a new contact — the same line\n * `campaignTouch` draws for attribution. Scalars and one string array, so\n * the runtime can flatten it into an event payload without inventing keys.\n */\nexport interface HostContactCreated {\n contactId: string\n hostId: string\n email: string\n name?: string\n source: ContactSource\n /** The capture surface's campaigns, normalized — `[]` when it had none. */\n campaignIds: string[]\n /**\n * The stage the create wrote onto the capturing facet — the door's\n * {@link UpsertHostContactOptions.initialLifecycleStage}, or the profile's\n * own — and absent when the capture named none, so an automation can\n * filter `lifecycleStage == \"lead\"` on the day the person appears.\n */\n lifecycleStage?: ContactLifecycleStage\n}\n\nexport interface UpsertHostContactOptions {\n hostId: string\n email: unknown\n name?: string\n source: ContactSource\n interaction: Omit<ContactInteraction, 'type' | 'atMs'> & { atMs?: number }\n /**\n * Explicit marketing opt-in (AGL-301) with a consent timestamp, recorded\n * against {@link hostId} — the brand whose form carried the checkbox.\n */\n marketingConsent?: boolean\n /**\n * An explicit REFUSAL, recorded against {@link hostId} (AGL-3185).\n *\n * Its own flag rather than `marketingConsent: false`, because every door\n * that passes a checkbox's value passes `false` for a box left alone, and\n * a box left alone records nothing — absence is the third state, and\n * turning it into a refusal would silently make every unticked visitor\n * unmailable. A door sets this only for an act of refusal: a switch turned\n * off, a prompt answered no. Ignored when {@link marketingConsent} is true.\n */\n declineMarketingConsent?: boolean\n /**\n * The provenance stored on the consent entry {@link marketingConsent} or\n * {@link declineMarketingConsent} writes (AGL-3185): who recorded it, which\n * door, which wording version. The console doors pass the person's own\n * (`actor: 'person'`); a site's capture surface passes none, which the\n * reader takes as the person's own act anyway.\n */\n marketingConsentSource?: MarketingConsentSource\n /**\n * Order value in cents — rolls into RFM fields (AGL-328).\n *\n * WHAT IT COUNTS (AGL-1748). GROSS of the platform fee and GROSS of\n * refunds — the money the customer handed over, not the money the merchant\n * kept. Every writer passes the same thing: whatever was actually charged\n * (`amount_total` for a Stripe path, `totals.totalCents` for POS), never a\n * figure re-derived from product docs, which is the AGL-1698/AGL-1711\n * lesson. Gross of the fee because this is a CUSTOMER attribute answering\n * \"what is this person worth to me?\", and the fee is a cost of the channel,\n * not something the buyer failed to spend.\n *\n * Refunds are still NOT netted here, and now they are recorded elsewhere\n * (AGL-1754). `refund.ts` writes `refundedCents`, `refundedOrdersCount` and\n * `lastRefundAtMs` BESIDE these fields — the shape AGL-1747 chose for the\n * same question on the orders CSV — rather than decrementing a stored number\n * whose meaning would then differ between rows written before and after that\n * commit. So `ltvCents` and `ordersCount` remain gross by definition, and a\n * READER that wants the net computes `ltvCents - refundedCents`, clamping\n * only what it ranks on: the difference can be negative for a customer whose\n * pre-AGL-1748 purchase was never counted and whose refund was, which is a\n * missing purchase showing itself rather than a corrupt contact. AGL-1753 is\n * the backfill that reconciles it. See `contact-refund.ts` in the commerce\n * plugin for the full reasoning and for why a refund never CREATES a contact.\n *\n * Passing 0 or omitting it means \"no purchase\": `ltvCents`, `ordersCount`,\n * `lastPurchaseAtMs` and `firstPurchaseAtMs` are all left untouched, which\n * is why a caller that formats the amount into the interaction summary and\n * forgets this field records a customer who has apparently never bought\n * anything.\n */\n purchaseCents?: number\n /**\n * The currency {@link purchaseCents} is in, lowercase, when the door knows.\n *\n * Absent everywhere today, because no order document carries a currency and\n * every checkout door writes `currency: 'usd'` onto the Stripe line items.\n * `attributeOrderToEmail` defaults it on that basis and says so. The field\n * exists so a door that ever charges in something else can pass it, and the\n * campaign revenue report keeps it in its own bucket rather than adding it\n * to the dollars.\n */\n purchaseCurrency?: string\n /**\n * The campaign this person came from, already resolved by the door.\n *\n * ⛔ The ORDER path passes none, and must not start. An order already has\n * its own join one branch below — `attributeOrderToEmail`, keyed on the\n * order id — and a second record for the same sale would be the same money\n * counted twice under two rules. This is the door for the moments an order\n * does NOT cover: a form submission, a membership sign-up, a booking, a\n * newsletter capture.\n *\n * Resolved rather than raw, for the reason `addHostLead` states: one\n * visitor action reaches several writers and the touch lookup is paid once.\n */\n campaignTouch?: ResolvedCampaignTouch | null\n /**\n * The campaigns the CAPTURE SURFACE is filed under.\n *\n * ⚠️ A different fact from {@link campaignTouch} beside it, and the two must\n * never be folded together. A touch is where the visitor came FROM — an ad,\n * a link, a browser-supplied label resolved through an allowlist. This is\n * which campaigns the merchant put the form itself in, which is the\n * merchant's own act and is true of everybody who fills that form in,\n * including the visitor who arrived by typing the address.\n *\n * ⛔ And it is not consent. Filing a form under a campaign says nothing\n * about what the person agreed to; `marketingConsent` above is the only\n * input that records a basis.\n */\n campaignIds?: readonly string[]\n /**\n * Tags to put on THIS holder's facet (AGL-2602).\n *\n * Added to, never replaced, on a merge: a person the merchant tagged by\n * hand and later imported keeps the hand-written tag beside the file's.\n */\n tags?: readonly string[]\n /**\n * The profile a door knows about the person — phone, title, company,\n * address, owner, stage, custom values — written into THIS holder's facet\n * (AGL-2602). Only the keys present are written, so a door that knows the\n * phone and nothing else does not blank the title somebody typed.\n */\n facet?: UpsertHostContactFacet\n /**\n * The EARLIEST stage that describes what this capture was (AGL-2612).\n *\n * Every door names one: a form submission is a `lead`, a newsletter opt-in\n * or a member sign-up is a `subscriber`, a purchase — an order, a paid\n * booking — is a `customer`. The rule applied to it is\n * `advanceContactLifecycleStage`: it fills an empty stage and advances an\n * earlier one, and never moves anybody back, so a customer who fills in\n * the contact form is still a customer and a subscriber who then submits\n * a form becomes a lead. Applied on top of `facet.lifecycleStage` when a\n * door carries both — the caller's stage is the base and this is its\n * floor.\n *\n * A FLOOR and not a value, which is why it is not the facet field: the\n * facet's `lifecycleStage` is a SET, the shape the console's create\n * drawer and the import need — the merchant typed a stage and that is the\n * stage — and a capture door writing a set would put every returning\n * customer back to `lead` on their next enquiry. Omitted by the doors\n * that carry the caller's own stage or none (manual, import, API, a lead\n * conversion), whose contacts read as \"no stage\", which is true.\n */\n initialLifecycleStage?: ContactLifecycleStage\n /**\n * Told when this capture created a contact (AGL-2605).\n *\n * A HOOK rather than an event emitted from here, and the reason is the\n * dependency direction: the event fan-out lives in `libs/tenant/runtime`,\n * which imports THIS library for its Firestore handle and its org helpers.\n * An import back up from here would be a cycle, and the module boundaries\n * (`scope:data` may depend on data and util only) refuse it besides. So\n * this module reports the fact and the runtime's `captureHostContact`\n * turns it into `contactCreated` — every server door goes through that\n * wrapper, and a door that calls this function directly has chosen to\n * create contacts nothing can react to.\n *\n * Awaited with its own catch, like the order join above: the least\n * important write on the path, and a failure in it must not cost the\n * capture that already happened.\n */\n onCreated?: (created: HostContactCreated) => void | Promise<void>\n}\n\nexport async function upsertHostContact(\n options: UpsertHostContactOptions,\n): Promise<UpsertHostContactVerdict> {\n try {\n const email = normalizeContactEmail(options.email)\n if (!email) return { refused: 'invalid-email' }\n const tags = normalizeTags(options.tags)\n\n /*==========================================\n * THE PURCHASE DOOR, AND THEREFORE THE ATTRIBUTION DOOR.\n *\n * Every way of buying something in this product — the cart, buy-now, the\n * POS register, a draft order, a reservation, a subscription renewal, a\n * booking — announces itself here, in exactly one shape: source `order`,\n * a `purchaseCents` amount, and a `refId` naming what was bought. That\n * shape IS the purchase chokepoint, which is why the revenue join hangs\n * off it rather than off seven call sites in a webhook.\n *\n * ABOVE the audience-band gate below, and deliberately. Contact creation\n * is band gated, so a Free org past its included count drops the CRM\n * record — and an attribution written inside that branch would drop the\n * revenue with it. The join keys on the address hash, exactly as the\n * touch and the suppression list do, so it never needs a contact document\n * to exist: a guest checkout by somebody who is not and never becomes a\n * contact still credits the campaign whose link they clicked.\n *\n * Its own `catch`, inside a function that already swallows: this is the\n * least important write on the path, and a failure here must not cost the\n * contact capture below it.\n *=========================================*/\n if (options.source === 'order' && options.interaction.refId) {\n await attributeOrderToEmail({\n hostId: options.hostId,\n orderId: String(options.interaction.refId),\n email,\n amountCents: Number(options.purchaseCents ?? 0),\n ...(options.purchaseCurrency\n ? { currency: options.purchaseCurrency }\n : {}),\n orderedAtMs: options.interaction.atMs ?? Date.now(),\n }).catch(() => null)\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(options.hostId)\n // Contacts are org-scoped (AGL-237): every host in the org feeds one\n // shared list.\n const contactsRef = await orgDataCollectionForHost(\n options.hostId,\n 'contacts',\n )\n /*\n * The consent group this capture belongs to — the sites declared to be\n * one sender, or this site alone. Resolved once and used for three\n * different decisions below, which must all agree: which controller the\n * basis is recorded for, which sites the row becomes visible to, and\n * whether the capture surface had to disclose anything.\n */\n const group = await consentGroupForSite(options.hostId)\n /*\n * THE CONSENT ENTRY THIS CAPTURE WRITES, decided once for both branches.\n *\n * A grant outranks a refusal flag set beside it — one door cannot mean\n * both — and neither writes anything when the door carried no act, so\n * an unticked box stays the third state. The provenance rides inside the\n * per-host entry, where the reader looks for it, never at the top of a\n * document every brand in the org shares.\n */\n const consentExtra = options.marketingConsentSource\n ? { [MARKETING_CONSENT_SOURCE_FIELD]: options.marketingConsentSource }\n : undefined\n const consentFields = options.marketingConsent\n ? marketingConsentFieldsForGroup(group, Date.now(), consentExtra)\n : options.declineMarketingConsent\n ? declineMarketingConsentFields(options.hostId, Date.now(), consentExtra)\n : {}\n const interaction: ContactInteraction = {\n type: options.source,\n atMs: options.interaction.atMs ?? Date.now(),\n // WHICH SITE this visit happened on. The row is shared; the history on\n // it is not, and a timeline with no site cannot be split for an\n // agency's client without showing them another client's activity.\n hostId: options.hostId,\n ...(options.interaction.refId\n ? { refId: options.interaction.refId }\n : {}),\n ...(options.interaction.summary\n ? { summary: options.interaction.summary.slice(0, 200) }\n : {}),\n // The entry point, when the door knows it. Written only when present:\n // an absent field is a door that has none, and Firestore rejects\n // `undefined` inside an array element outright.\n ...(options.interaction.formId\n ? { formId: String(options.interaction.formId).slice(0, 128) }\n : {}),\n ...(options.interaction.path\n ? { path: String(options.interaction.path).slice(0, 500) }\n : {}),\n }\n\n /*\n * THE CAMPAIGNS THIS CAPTURE FILES THE PERSON UNDER.\n *\n * Normalized here rather than trusted, because it reaches this function\n * from a public endpoint's document read and every reader of the stored\n * array goes through the same coercion.\n */\n const campaignIds = normalizeCampaignIds(options.campaignIds ?? [])\n /*\n * THE CUSTOM FIELD VALUES THIS CAPTURE CARRIES, as one nested map.\n *\n * Only the keys the door resolved. Written in the NESTED form because both\n * writes below are merge-sets, which deep-merge a map one key at a time:\n * `custom: { tier: 'Gold' }` lands beside an existing `custom.vip` and\n * leaves it standing. A dotted `facets.h1.custom.tier` path would be a\n * literal field name to a `set`, and a `custom` written whole would take\n * every other key with it.\n */\n const customEntries = Object.entries(options.facet?.custom ?? {})\n const customFacet = customEntries.length\n ? { custom: Object.fromEntries(customEntries) }\n : {}\n\n /*==========================================\n * THE DEDUPE LOOKUP IS UNSCOPED, AND HAS TO BE.\n *\n * One human who touched two sites is ONE person. Narrowing this read to\n * what the capturing site may already see would make a second submission\n * on a sibling brand create a SECOND document for the same address —\n * which loses the dedupe the shared address book exists for, and bills\n * the org twice for one human.\n *\n * Recognizing somebody is not the same act as being allowed to read their\n * row, and the two were the same query while every contact was stamped\n * org-wide. They are separated here: this finds the person, and\n * `visibleTo` below decides who may see them — widened by the capture\n * that just happened, never by the lookup that found them.\n *\n * THROUGH THE ADDRESS INDEX FIRST (AGL-2625). A person whose two records\n * were merged answers to two addresses, and only one of them is the\n * document's `email`; the index is what lets a capture on the other one\n * land on the survivor instead of minting the duplicate again. The query\n * stays as the fallback, so a row the index has not seen costs one extra\n * read once and needs no backfill.\n *=========================================*/\n const docSnapshot = await findContactByEmail(contactsRef, email)\n\n if (docSnapshot) {\n /*\n * MERGED INTO THIS GROUP'S FACET, not into the top of the document.\n *\n * `sources`, `interactions`, the tags, the notes and every commercial\n * figure are the HOLDER's own business records: a booking taken by one\n * client of an agency is that client's, and while these lived at the\n * top of a shared row every other client could read them. The identity\n * — the address, and a canonical name for a holder that has set none of\n * its own — stays shared, because that is what makes this one row.\n */\n const facet = readContactFacet(\n docSnapshot.data() as Record<string, unknown>,\n group.groupId,\n )\n const merged = mergeContactInteraction(\n {\n name: facet.name ?? undefined,\n sources: facet.sources,\n interactions: facet.interactions,\n },\n { source: options.source, interaction, name: options.name },\n )\n /*\n * THE PROFILE, as this door knows it (AGL-2596).\n *\n * Given keys only, so the merge below leaves untouched whatever another\n * door wrote. The stage is the one field with a rule of its own: the\n * door's `initialLifecycleStage` fills an empty stage and advances an\n * earlier one, and never moves anybody back —\n * `advanceContactLifecycleStage` is that rule, applied to the stage\n * this door asked for outright or, failing that, the one already\n * stored. Written only when it comes back with something: a door that\n * named no stage and found none leaves the key absent, which reads as\n * \"no stage\" rather than as a stage somebody picked.\n */\n const profile = storableProfile(options.facet)\n const advanced = advanceContactLifecycleStage(\n profile.lifecycleStage ?? facet.lifecycleStage,\n options.initialLifecycleStage,\n )\n if (advanced) profile.lifecycleStage = advanced\n /*\n * THE FORM MIRROR, bounded (see `HostContact.formIds`).\n *\n * Read off the document the lookup already fetched, so the bound costs\n * no extra read: a person who has come in through twenty forms keeps\n * the twenty, and this capture's form stays on the interaction alone.\n * `arrayUnion` for the add, so a concurrent capture on a sibling site\n * cannot drop this one's id.\n */\n const heldFormIds: unknown[] = Array.isArray(\n docSnapshot.get(CONTACT_FORM_IDS_FIELD),\n )\n ? docSnapshot.get(CONTACT_FORM_IDS_FIELD)\n : []\n const formIdToAdd =\n interaction.formId &&\n !heldFormIds.includes(interaction.formId) &&\n heldFormIds.length < CONTACT_FORM_IDS_CAP\n ? interaction.formId\n : null\n /*\n * THE COMPANY LINK, when the door carried one (AGL-2613).\n *\n * The facet's `companyId` is written with the rest of the profile\n * below, but the facet is not the whole association: the top-level\n * `companyIds` mirror is what the company page queries, and the\n * company's `contactsCount` is what the companies list shows. The\n * planner reads the row as it stands — this holder's previous link,\n * the mirror, the other holders' ids — and says what the mirror\n * becomes and which counts move; the mirror change rides in this same\n * merge-set, and the counts settle after it.\n */\n const link =\n profile.companyId !== undefined\n ? planContactCompanyLink(\n readContactCompanyLink(\n docSnapshot.data() as Record<string, unknown>,\n group.groupId,\n ),\n profile.companyId,\n )\n : null\n const mirror = link ? contactCompanyMirrorValue(link) : undefined\n await docSnapshot.ref.set(\n {\n // The search keys travel WITH the name, and only when the name is\n // written: stamping an empty key over a real one would make the\n // contact unfindable by the name it still displays.\n ...(merged.name ? nameSearchFields(merged.name) : {}),\n // The search echo of the facet's phone — see `HostContact.phone`.\n ...(profile.phone ? { phone: profile.phone } : {}),\n // The search echo of the facet's company name — see `HostContact.companyName`.\n ...(profile.companyName ? { companyName: profile.companyName } : {}),\n ...(mirror !== undefined ? { [CONTACT_COMPANY_IDS_FIELD]: mirror } : {}),\n /*\n * NESTED, not dot-pathed. This is a `set(…, { merge: true })`, and\n * a merge-set treats a key containing dots as a literal field name\n * — only `update()` reads them as paths. The nested form is what\n * Firestore deep-merges, so this writes one holder's facet and\n * leaves every other holder's untouched.\n */\n [CONTACT_FACETS_FIELD]: {\n [group.groupId]: {\n sources: merged.sources,\n interactions: merged.interactions,\n ...(merged.name ? { name: merged.name } : {}),\n /*\n * ADDED TO, never replaced. A person who filled in the spring\n * form and later the summer one is in both pushes, and an\n * assignment that overwrote would take a campaign a merchant\n * filed them under back out with nothing on screen to say so —\n * the reason `campaign-membership.ts` made the field an array\n * and the automation step has always used `arrayUnion`.\n *\n * Nested under the group id rather than written at\n * `contactCampaignFieldPath`, because this is a merge-`set`: a\n * `set` treats a dotted string as a literal field NAME and would\n * mint a top-level key with dots in it. Only `update()` reads\n * dots as a path. The nested form is what Firestore deep-merges,\n * so it reaches one holder's facet and leaves every other\n * holder's alone — the same guarantee the dotted path gives the\n * automation step, in the shape this write is allowed to take.\n */\n ...(campaignIds.length\n ? { campaignIds: FieldValue.arrayUnion(...campaignIds) }\n : {}),\n // The same union rule as the campaigns above it, for the same\n // reason: a tag the merchant put on by hand survives the file.\n ...(tags.length ? { tags: FieldValue.arrayUnion(...tags) } : {}),\n // Present keys only, deep-merged by the merge-set — so the\n // fields this door did not carry keep whatever they held.\n ...profile,\n ...(options.purchaseCents\n ? {\n ltvCents: FieldValue.increment(options.purchaseCents),\n ordersCount: FieldValue.increment(1),\n lastPurchaseAtMs: Date.now(),\n // A contact that EXISTED before their first purchase\n // reached this branch, and it never wrote\n // `firstPurchaseAtMs` — only the create path did. So\n // every converted lead permanently lacked RFM's R anchor\n // while walk-in buyers carried it. Set it on the first\n // purchase only; later purchases must not move it.\n ...(facet.firstPurchaseAtMs\n ? {}\n : { firstPurchaseAtMs: Date.now() }),\n }\n : {}),\n },\n },\n /*\n * ATTRIBUTION GROWS ON THE MERGE BRANCH, which the create-only\n * `hostId` beside it never did — so a person the first site\n * captured and the second site later met read as the first site's\n * alone, forever.\n *\n * `arrayUnion`, so the audience filter \"everyone captured on A, B\n * or C\" answers with the sites that actually met this person.\n */\n [CAPTURED_BY_HOST_FIELD]: FieldValue.arrayUnion(options.hostId),\n ...(formIdToAdd\n ? { [CONTACT_FORM_IDS_FIELD]: FieldValue.arrayUnion(formIdToAdd) }\n : {}),\n /*\n * AND SO DOES VISIBILITY — by the capture, never by the lookup.\n *\n * This site just collected this person: it has its own relationship\n * with them and may see the row. A site that has never captured\n * them gains nothing here, which is what keeps an agency's clients\n * apart on a document all of them share. The per-interaction\n * `hostId` above is what keeps the HISTORY apart on the same row.\n */\n visibleTo: FieldValue.arrayUnion(...consentGroupScope(group)),\n /*\n * RECORDED AGAINST THE CAPTURING SITE, not against the org.\n *\n * The contact document is shared by every site in the org, which is\n * the point of it — one address book behind many brands. Its\n * consent is not shared on the same terms: the checkbox this\n * capture carried was ticked under one brand's name, on one brand's\n * form, and a basis written at the top of this document made the\n * person mailable by every other brand the account holds.\n *\n * A merge writes one key of the map and leaves the rest, so a\n * person who opts in to a second site accumulates two grants rather\n * than replacing the first.\n */\n ...consentFields,\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n await settleCompanyContactsCounts(companiesBeside(contactsRef), link)\n return { contactId: docSnapshot.id, created: false }\n }\n\n /*\n * THE ERASED PERSON DOES NOT COME BACK (AGL-2623).\n *\n * Before the band, on the CREATE branch only — the one read this adds\n * is paid by a capture that would otherwise make a new row, never by\n * the merge above. An erasure wrote a row on this site's suppression\n * list with no address on it; finding that row here is what keeps the\n * next form fill or checkout from rebuilding the record the workspace\n * just erased. The order or booking that triggered the capture still\n * succeeds, exactly as it does when the band refuses: the person bought\n * something, and the merchant's record of the sale is not the CRM's\n * record of the person.\n */\n if (await hostRefusesCaptureForErasure(options.hostId, email, firestore)) {\n return { refused: 'erased' }\n }\n\n // New contact: records-band check via the aggregate counts (cheap; no\n // doc reads) against the owning org's entitlements (AGL-238/890).\n // Metered plans always pass (overage bills via the report-usage\n // cron); only free's hard band drops the CRM record — visibly, via\n // the counter and the console alert (AGL-891). The signup/order that\n // triggered the capture always succeeds either way.\n //\n // THE RECORDS BAND, not the contacts headcount (AGL-2611): companies\n // and deals share this band, so a capture is refused on a Free org\n // whose hundred records are ninety companies and ten people — the same\n // verdict the company drawer would give the ninety-first company.\n // `contactsRef` is handed in so the contacts aggregate is the one this\n // door already reads; the org reference is its parent.\n const orgBilling = await getOrgForHost(options.hostId)\n const orgRef =\n contactsRef.parent ??\n firestore.collection('orgs').doc(String(orgBilling?.orgId ?? ''))\n const { crmRecordsCount } = await countCrmRecords(orgRef, contactsRef)\n const quota = checkCrmRecordsQuota(\n (orgBilling?.org as any) ?? null,\n crmRecordsCount,\n )\n if (!quota.allowed) {\n await hostRef\n .collection('counters')\n .doc('contactsDropped')\n .set({ total: FieldValue.increment(1) }, { merge: true })\n return { refused: 'band' }\n }\n\n /*\n * The profile on a create, with the door's stage floor applied the same\n * way as on a merge — there is no stored stage yet, so the floor is what\n * a door that named one writes. A `null` address is dropped rather than\n * written: there is nothing on a new document for it to clear.\n */\n const profile = storableProfile(options.facet)\n const advanced = advanceContactLifecycleStage(\n profile.lifecycleStage,\n options.initialLifecycleStage,\n )\n if (advanced) profile.lifecycleStage = advanced\n if (profile.address === null) delete profile.address\n\n const created = await contactsRef.add({\n hostId: options.hostId,\n /*\n * WHICH SITES HAVE MET THIS PERSON — attribution, and separate from\n * consent above.\n *\n * The scalar `hostId` beside it names the FIRST capturing site and is\n * never rewritten, which is provenance for the ROW. This array is the\n * one that grows and the one an audience query can filter on.\n */\n [CAPTURED_BY_HOST_FIELD]: [options.hostId],\n // The form mirror's first entry, when this capture came through one —\n // see `HostContact.formIds`. Absent otherwise, like the campaigns.\n ...(interaction.formId\n ? { [CONTACT_FORM_IDS_FIELD]: [interaction.formId] }\n : {}),\n /*\n * THE CAPTURING GROUP, not the whole org.\n *\n * Stamping `['org']` here made every contact readable by every site in\n * the account on the day it was created — so an agency's twelve clients\n * shared one address book by default, and closing the missing-field\n * fail-open would not have touched it, because the field was present\n * and said so.\n *\n * A group of one — the default — is this site alone. A declared group\n * is the sites that already present as one sender. Widening beyond that\n * is available and is an ACT: an org may set `defaultResourceScope` to\n * `'org'`, or a later capture on a sibling site unions that site in.\n */\n visibleTo:\n (orgBilling?.org as { defaultResourceScope?: 'org' | 'host' } | null)\n ?.defaultResourceScope === 'org'\n ? [ORG_SCOPE_TOKEN]\n : consentGroupScope(group),\n email,\n ...(options.name ? nameSearchFields(options.name.slice(0, 120)) : {}),\n // The search echo of the facet's phone — see `HostContact.phone`.\n ...(profile.phone ? { phone: profile.phone } : {}),\n // The search echo of the facet's company name — see `HostContact.companyName`.\n ...(profile.companyName ? { companyName: profile.companyName } : {}),\n // The company mirror the company page queries (AGL-2613) — a create\n // has nothing to union with, so the door's company is the whole list.\n ...(profile.companyId\n ? { [CONTACT_COMPANY_IDS_FIELD]: [profile.companyId] }\n : {}),\n // The facet this capture creates. Everything a holder owns lives under\n // its own group id; the address and the canonical name above are the\n // only shared identity.\n [CONTACT_FACETS_FIELD]: {\n [group.groupId]: {\n sources: { [options.source]: true },\n interactions: [interaction],\n tags,\n // A create has nothing to union with, so the normalized list is the\n // whole membership.\n ...(campaignIds.length ? { campaignIds } : {}),\n ...customFacet,\n ...(options.name\n ? { name: options.name.slice(0, 120) }\n : {}),\n ...profile,\n ...(options.purchaseCents\n ? {\n ltvCents: options.purchaseCents,\n ordersCount: 1,\n lastPurchaseAtMs: Date.now(),\n firstPurchaseAtMs: Date.now(),\n }\n : {}),\n },\n },\n ...consentFields,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n // The address now resolves to this row (AGL-2625). Its own catch inside\n // the writer, so an index the capture could not reach costs nothing.\n await writeContactEmailIndex(emailIndexBeside(contactsRef), created.id, [email])\n // The company the door named now has one more contact naming it\n // (AGL-2613) — the fresh row's plan is the trivial one, nothing held\n // before and nothing held elsewhere.\n if (profile.companyId) {\n await settleCompanyContactsCounts(\n companiesBeside(contactsRef),\n planContactCompanyLink(\n { companyId: null, companyIds: [], heldElsewhere: [] },\n profile.companyId,\n ),\n )\n }\n /*\n * ATTRIBUTED ON CREATION ONLY, and therefore below the band gate rather\n * than above it — the opposite placement to the order join at the top of\n * this function, deliberately.\n *\n * The order join sits above because it credits MONEY, which is real\n * whether or not a CRM record was kept for the buyer, and a Free org past\n * its included band would otherwise silently drop the revenue with the\n * contact. This credits a contact, which does not exist when the band\n * dropped it: attributing one above the gate would report a campaign as\n * having produced people the customer cannot see anywhere in the console.\n *\n * The existing-contact branch above returns without reaching here, which\n * is the same rule `addHostLead` applies — an interaction appended to\n * somebody the site already held is another visit, not a new person, and\n * crediting it would let the most recent campaign re-earn the whole list.\n */\n if (options.campaignTouch) {\n await attributeCampaignConversion({\n hostId: options.hostId,\n kind: 'contact',\n refId: created.id,\n touch: options.campaignTouch,\n convertedAtMs: interaction.atMs,\n })\n }\n /*\n * REPORTED ON CREATION ONLY, below the band gate for the reason the\n * attribution above is: a contact the band dropped does not exist, and\n * an automation told about it would act on a person the console cannot\n * show. The merge branch returned long before this line, so a repeat\n * visit by somebody already held is never announced as a new contact.\n */\n if (options.onCreated) {\n await Promise.resolve(\n options.onCreated({\n contactId: created.id,\n hostId: options.hostId,\n email,\n ...(options.name ? { name: options.name.slice(0, 120) } : {}),\n source: options.source,\n campaignIds,\n ...(profile.lifecycleStage\n ? { lifecycleStage: profile.lifecycleStage }\n : {}),\n }),\n ).catch((error) => {\n console.error('upsertHostContact onCreated failed', error)\n })\n }\n return { contactId: created.id, created: true }\n } catch (error) {\n console.error('upsertHostContact failed', error)\n return { refused: 'error' }\n }\n}\n"],"names":["CAPTURED_BY_HOST_FIELD","checkCrmRecordsQuota","consentGroupScope","CONTACT_FACETS_FIELD","CONTACT_FORM_IDS_CAP","CONTACT_FORM_IDS_FIELD","marketingConsentFieldsForGroup","mergeContactInteraction","normalizeCampaignIds","readContactFacet","normalizeContactEmail","ORG_SCOPE_TOKEN","FieldValue","firebaseAdmin","countCrmRecords","attributeOrderToEmail","hostRefusesCaptureForErasure","attributeCampaignConversion","nameSearchFields","declineMarketingConsentFields","MARKETING_CONSENT_SOURCE_FIELD","advanceContactLifecycleStage","CONTACT_COMPANY_IDS_FIELD","CONTACT_FIELD_KEY_PATTERN","CRM_COLLECTIONS","isContactLifecycleStage","planContactCompanyLink","readContactCompanyLink","normalizeAddress","normalizePhone","contactCompanyMirrorValue","settleCompanyContactsCounts","emailIndexBeside","findContactByEmail","writeContactEmailIndex","consentGroupForSite","getOrgForHost","orgDataCollectionForHost","storableProfile","input","out","phone","undefined","jobTitle","trim","slice","address","companyId","companyName","ownerUid","lifecycleStage","custom","key","value","Object","entries","test","keys","length","companiesBeside","contactsRef","parent","collection","companies","normalizeTags","tags","Set","map","tag","String","toLowerCase","filter","Boolean","upsertHostContact","options","orgBilling","email","refused","source","interaction","refId","hostId","orderId","amountCents","Number","purchaseCents","purchaseCurrency","currency","orderedAtMs","atMs","Date","now","catch","firestore","app","hostRef","doc","group","consentExtra","marketingConsentSource","consentFields","marketingConsent","declineMarketingConsent","type","summary","formId","path","campaignIds","customEntries","facet","customFacet","fromEntries","docSnapshot","profile","data","groupId","merged","name","sources","interactions","advanced","initialLifecycleStage","heldFormIds","Array","isArray","get","formIdToAdd","includes","link","mirror","ref","set","arrayUnion","ltvCents","increment","ordersCount","lastPurchaseAtMs","firstPurchaseAtMs","visibleTo","updatedAt","serverTimestamp","merge","contactId","id","created","orgRef","orgId","crmRecordsCount","quota","org","allowed","total","add","defaultResourceScope","createdAt","companyIds","heldElsewhere","campaignTouch","kind","touch","convertedAtMs","onCreated","Promise","resolve","error","console"],"mappings":"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"}