@aglyn/tenant-data-admin 1.0.0-beta.233 → 1.0.0-beta.235
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.
|
|
3
|
+
"version": "1.0.0-beta.235",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,12 +25,12 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-util-fbserver": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-util-first-touch": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.235",
|
|
29
|
+
"@aglyn/shared-util-email": "1.0.0-beta.235",
|
|
30
|
+
"@aglyn/shared-util-fbserver": "1.0.0-beta.235",
|
|
31
|
+
"@aglyn/shared-util-first-touch": "1.0.0-beta.235",
|
|
32
|
+
"@aglyn/shared-util-http": "1.0.0-beta.235",
|
|
33
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.235",
|
|
34
34
|
"@msgpack/msgpack": "^3.1.3",
|
|
35
35
|
"@swc/helpers": "0.5.23",
|
|
36
36
|
"sharp": "^0.35.5",
|
|
@@ -244,3 +244,17 @@ export declare function setClaimsInOwningPool(uid: string, claims: Record<string
|
|
|
244
244
|
* claim, which is not queryable, so this scans — the caller caches it.
|
|
245
245
|
*/
|
|
246
246
|
export declare function listStaffUidsAcrossPools(): Promise<string[]>;
|
|
247
|
+
/**
|
|
248
|
+
* How many accounts the staff Users list holds (AGL-3660).
|
|
249
|
+
*
|
|
250
|
+
* Firebase Auth has no count aggregate, and the Users list is Auth across
|
|
251
|
+
* every pool — not the Firestore `users/{uid}` profile docs, which are made on
|
|
252
|
+
* demand and can be missing for a real account or outlive an erased one. So
|
|
253
|
+
* the count walks the same directory the list does and collapses a uid that
|
|
254
|
+
* shows in more than one pool to one person, exactly as the list's rows do.
|
|
255
|
+
* `truncated` means the cap was reached and the figure is a floor.
|
|
256
|
+
*/
|
|
257
|
+
export declare function countUsersAcrossPools(cap?: number): Promise<{
|
|
258
|
+
count: number;
|
|
259
|
+
truncated: boolean;
|
|
260
|
+
}>;
|
|
@@ -547,5 +547,21 @@ export async function listAuthTenantIds() {
|
|
|
547
547
|
...tenantUidLists.flat()
|
|
548
548
|
];
|
|
549
549
|
}
|
|
550
|
+
/**
|
|
551
|
+
* How many accounts the staff Users list holds (AGL-3660).
|
|
552
|
+
*
|
|
553
|
+
* Firebase Auth has no count aggregate, and the Users list is Auth across
|
|
554
|
+
* every pool — not the Firestore `users/{uid}` profile docs, which are made on
|
|
555
|
+
* demand and can be missing for a real account or outlive an erased one. So
|
|
556
|
+
* the count walks the same directory the list does and collapses a uid that
|
|
557
|
+
* shows in more than one pool to one person, exactly as the list's rows do.
|
|
558
|
+
* `truncated` means the cap was reached and the figure is a floor.
|
|
559
|
+
*/ export async function countUsersAcrossPools(cap = 20000) {
|
|
560
|
+
const scan = await scanUsersAcrossPools(cap);
|
|
561
|
+
return {
|
|
562
|
+
count: collapseCrossPoolUidRows(scan.users).length,
|
|
563
|
+
truncated: scan.truncated || scan.tenantTruncated.length > 0
|
|
564
|
+
};
|
|
565
|
+
}
|
|
550
566
|
|
|
551
567
|
//# sourceMappingURL=auth-pools.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/auth-pools.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Cross-pool Firebase Auth lookups (AGL-1122).\n *\n * Enterprise SSO (AGL-1101) signs users into a per-org **GCIP tenant**, and a\n * tenant has its OWN user pool. A project-level `getUserByEmail` /\n * `listUsers` does not see those users at all — measured on aglyn-main:\n * the owner of aglyn-org returned `auth/user-not-found` at project level and\n * was present only under tenant `aglyn-org-y5v14`.\n *\n * That silently broke five separate things: staff could not find, view,\n * disable or grant staff to an SSO user; adding one to an org or a site by\n * email 404'd into \"send them an invite\" for an account that already exists;\n * and `notifyStaff` skipped SSO staff entirely. Fixing those one route at a\n * time would guarantee the next new route reintroduces the bug, so every\n * caller goes through here instead.\n *\n * Custom claims are per-pool too: `setCustomUserClaims` on the project pool\n * cannot grant staff to a tenant user. `authForPool` hands back the right\n * `BaseAuth` so a mutation lands in the pool the user actually lives in.\n */\n\nimport type { BaseAuth, UserRecord } from 'firebase-admin/auth'\nimport firebaseAdmin from './firebase-admin'\n\nconst auth = () => firebaseAdmin.app().auth()\n\n/** A user plus the pool they were found in (`null` = the project pool). */\nexport interface PooledUserRecord {\n record: UserRecord\n /** GCIP tenant id, or null when the user is in the project-level pool. */\n tenantId: string | null\n /**\n * Other pools that ALSO hold this uid (AGL-1962). Normally absent: a uid is\n * unique within a pool, and the pools are meant to hold different people.\n *\n * It is populated when the same uid turns up in more than one pool, which\n * means something minted a custom token for one pool's uid against another\n * pool — `signInWithCustomToken` CREATES the account when the uid is absent,\n * so the mistake manufactures an empty shadow account rather than failing.\n * Measured on production: `QQ7fixtureUid0000000000000001` existed in both the\n * project pool and `aglyn-org-y5v14`, the project copy created by\n * `/api/presence/token` minting an SSO user's tenant uid at project level.\n *\n * This is the marker, not a deletion: `listUsersAcrossPools` still returns\n * every record, and `collapseCrossPoolUidRows` — which is what a display\n * uses — keeps this field on the row it keeps, so a collision is still\n * legible after the rows are merged (AGL-2005).\n */\n uidAlsoInPools?: (string | null)[]\n}\n\n/**\n * Does this record show that a human ever authenticated as it? (AGL-2005)\n *\n * An address or a provider entry is the evidence. `signInWithCustomToken`\n * mints an account out of nothing when the uid is absent from the pool the\n * token was minted in, and the record it manufactures has neither: measured\n * on production, a project-pool twin of a tenant uid had `email: null` and\n * `providerData: []` while the real SSO record carried both an address and\n * `saml.aglyn-workspace`.\n *\n * `displayName` and custom claims deliberately do NOT count. Both can be\n * written onto any record after the fact — `updateProfile` and `grantStaff`\n * in the staff console do exactly that — so a shadow that a staff action had\n * already landed on would start qualifying as an identity and go on winning\n * the lookups it should be losing. The bar has to be a signal the forgery\n * cannot acquire by being acted upon.\n */\nexport function isIdentifiedUserRecord(record: UserRecord): boolean {\n if (!record) return false\n return Boolean(record.email) || (record.providerData?.length ?? 0) > 0\n}\n\n/**\n * How strongly a record identifies its human, for choosing between two that\n * share a uid (AGL-2005). Ordering only — the numbers have no other meaning.\n *\n * Email and provider data lead because they are what `isIdentifiedUserRecord`\n * trusts; the rest break ties between records that both qualify.\n */\nexport function identityStrength(record: UserRecord): number {\n if (!record) return 0\n let score = 0\n if (record.email) score += 8\n if ((record.providerData?.length ?? 0) > 0) score += 4\n if (record.phoneNumber) score += 2\n if (record.displayName) score += 1\n if (Object.keys(record.customClaims ?? {}).length > 0) score += 1\n return score\n}\n\n/**\n * Collapse rows sharing a uid to ONE row apiece (AGL-2005).\n *\n * **decided:** \"We still have two users list in this list with the\n * same uid but one without an email attached, this needs fixed we should only\n * see one user, even if they are sso.\"\n *\n * AGL-1962 deliberately refused to merge, on the grounds that collapsing two\n * accounts could hide an identity split. That reasoning still stands for two\n * DISTINCT accounts — which is why this keys on **uid** and never on email.\n * Two people who happen to share an address are two accounts with two uids\n * and stay two rows; merging them on email would delete a real user from the\n * staff console, a worse bug than the one being fixed and an invisible one.\n *\n * A repeated uid is different in kind: a uid is unique WITHIN a pool, so the\n * same uid in two pools is not two people, it is one person plus an artifact\n * of a cross-pool custom-token mint. Those merge.\n *\n * Two rules make the merge safe:\n *\n * - **The identified record wins.** Ranked by `identityStrength`, so an\n * emailless, providerless artifact can never be the row staff see — the\n * exact inversion that let the shadow win every uid lookup.\n * - **The collision is not discarded.** `markCrossPoolUidCollisions` runs\n * here rather than being left to the caller, so the surviving row always\n * carries `uidAlsoInPools` naming the pools that were folded into it. One\n * row, and it still says it was two.\n *\n * The survivor takes the FIRST occurrence's position so list ordering does\n * not shuffle, and ties keep the earlier row so the result is deterministic.\n */\nexport function collapseCrossPoolUidRows(\n users: PooledUserRecord[],\n): PooledUserRecord[] {\n // Marked here, not by the caller. A caller that forgot would silently turn\n // this into the cover-up the merge is explicitly not allowed to be.\n const marked = markCrossPoolUidCollisions(users)\n const winnerByUid = new Map<string, PooledUserRecord>()\n for (const user of marked) {\n const incumbent = winnerByUid.get(user.record.uid)\n if (\n !incumbent ||\n identityStrength(user.record) > identityStrength(incumbent.record)\n ) {\n winnerByUid.set(user.record.uid, user)\n }\n }\n const emitted = new Set<string>()\n const collapsed: PooledUserRecord[] = []\n for (const user of marked) {\n const uid = user.record.uid\n if (emitted.has(uid)) continue\n emitted.add(uid)\n collapsed.push(winnerByUid.get(uid) ?? user)\n }\n return collapsed\n}\n\n/**\n * Flag every uid that appears in more than one pool, in place of hiding it.\n *\n * Pure and total, so a spec can drive it without Firebase. Callers must pass\n * the rows they intend to show together — a collision it cannot see is one\n * it cannot report.\n */\nexport function markCrossPoolUidCollisions(\n users: PooledUserRecord[],\n): PooledUserRecord[] {\n const poolsByUid = new Map<string, (string | null)[]>()\n for (const user of users) {\n const pools = poolsByUid.get(user.record.uid) ?? []\n pools.push(user.tenantId)\n poolsByUid.set(user.record.uid, pools)\n }\n return users.map((user) => {\n const pools = poolsByUid.get(user.record.uid) ?? []\n if (pools.length < 2) return user\n // Everyone holding this uid EXCEPT this row's own pool. Compared by\n // index, not by value: two rows from the same pool would be a Firebase\n // impossibility, but filtering by value would silently drop a real one.\n const others = pools.filter((_, index) => index !== pools.indexOf(user.tenantId))\n return { ...user, uidAlsoInPools: others }\n })\n}\n\n/**\n * Every GCIP tenant on the project. Cached briefly: this is asked once per\n * lookup and the set changes only when an enterprise org is onboarded.\n * Fails soft to an empty list — a tenant-listing outage must degrade to\n * today's project-only behaviour rather than break sign-in-adjacent routes.\n */\nlet tenantCache: { ids: string[]; at: number } | null = null\nconst TENANT_CACHE_MS = 60_000\n\nexport async function listAuthTenantIds(): Promise<string[]> {\n const now = Date.now()\n if (tenantCache && now - tenantCache.at < TENANT_CACHE_MS) {\n return tenantCache.ids\n }\n try {\n const ids: string[] = []\n let pageToken: string | undefined\n do {\n const page = await auth().tenantManager().listTenants(100, pageToken)\n ids.push(...page.tenants.map((tenant) => tenant.tenantId))\n pageToken = page.pageToken\n } while (pageToken)\n tenantCache = { ids, at: now }\n return ids\n } catch (error) {\n console.error('tenant listing failed; falling back to project pool', error)\n return tenantCache?.ids ?? []\n }\n}\n\n/** Test seam — drops the tenant cache so a spec can vary the tenant set. */\nexport function resetAuthTenantCache(): void {\n tenantCache = null\n}\n\n/** The `BaseAuth` for a pool: the project pool for null, else the tenant's. */\nexport function authForPool(tenantId: string | null | undefined): BaseAuth {\n return tenantId\n ? auth().tenantManager().authForTenant(tenantId)\n : auth()\n}\n\n/**\n * Find a user by email across the project pool and every GCIP tenant.\n *\n * The project pool is tried first — it holds every non-SSO user, which is\n * almost all of them, so the common case stays a single call. Returns null\n * when no pool has the address, which is the only honest answer for \"this\n * person has no Aglyn account\".\n */\nexport async function findUserByEmailAcrossPools(\n email: string,\n): Promise<PooledUserRecord | null> {\n const address = email.trim().toLowerCase()\n if (!address) return null\n try {\n return { record: await auth().getUserByEmail(address), tenantId: null }\n } catch {\n // Not in the project pool — fall through to the tenants.\n }\n for (const tenantId of await listAuthTenantIds()) {\n try {\n const record = await authForPool(tenantId).getUserByEmail(address)\n return { record, tenantId }\n } catch {\n // Not in this tenant; keep looking.\n }\n }\n return null\n}\n\n/**\n * Find a user by uid across the project pool and every GCIP tenant. Same\n * shape as the email lookup — a uid is only unique WITHIN a pool, so the\n * caller needs to know which pool answered before it mutates anything.\n *\n * **First pool to answer is NOT good enough** (AGL-2005). This used to return\n * the project pool's record the moment it existed, which is how a forged\n * emailless twin won every uid lookup for a real SSO user: `grantStaff`,\n * `disable`, `setRole`, `updateProfile`, `revokeRefreshTokens`, impersonate,\n * usage-email, resolve-people, org-lockdown and erasure all landed on a\n * record nobody can sign in as. Two of those were caught having happened —\n * a `revokeRefreshTokens` dated 2026-08-14 sat on the twin while the real\n * account's `tokensValidAfterTime` never moved, so \"sign out everywhere\" did\n * nothing at all.\n *\n * So a pool's answer is accepted only when the record it returns actually\n * identifies someone, and the winner is chosen by the SAME rule the staff\n * list collapses rows with. That equivalence is the point: the row staff\n * click is the record the action mutates, by construction rather than by\n * coincidence.\n *\n * The fast path survives intact — a project account with an address or a\n * provider is returned without any tenant call, which is nearly every\n * account. Only a record carrying no identity at all pays for the sweep.\n */\nexport async function findUserByUidAcrossPools(\n uid: string,\n): Promise<PooledUserRecord | null> {\n if (!uid) return null\n const matches: PooledUserRecord[] = []\n try {\n const record = await auth().getUser(uid)\n if (isIdentifiedUserRecord(record)) return { record, tenantId: null }\n // Held, not returned. It is still the answer if nothing better exists —\n // an anonymous or half-created account legitimately looks like this, and\n // refusing it here would break every caller that handles those.\n matches.push({ record, tenantId: null })\n } catch {\n // Not in the project pool.\n }\n for (const tenantId of await listAuthTenantIds()) {\n try {\n const record = await authForPool(tenantId).getUser(uid)\n matches.push({ record, tenantId })\n // Stop at the first pool that knows who this is. Scanning the rest\n // would cost a call per enterprise tenant on a path that runs during\n // sign-in and billing, and a uid identified in two tenant pools is not\n // a shape Firebase can produce — tenant uids are minted per pool.\n if (isIdentifiedUserRecord(record)) break\n } catch {\n // Keep looking.\n }\n }\n if (!matches.length) return null\n return collapseCrossPoolUidRows(matches)[0] ?? null\n}\n\nexport interface PooledUserPage {\n users: PooledUserRecord[]\n /** Project-pool cursor; null when the project pool is exhausted. */\n nextPageToken: string | null\n /**\n * True once the project pool is exhausted and the tenant users have been\n * appended. Tenant pools are NOT paginated here — enterprise SSO pools are\n * small, and silently dropping their tail would recreate exactly the\n * invisibility this module exists to fix. `tenantTruncated` says when that\n * assumption stopped holding rather than letting it fail quietly.\n */\n tenantsIncluded: boolean\n /** Tenants whose users hit the cap and were cut short. */\n tenantTruncated: string[]\n}\n\n/** How many tenant users a single page will carry before reporting a cut. */\nconst TENANT_USER_CAP = 500\n\n/**\n * How many pools may have a call in flight at once.\n *\n * Every sweep below asks each GCIP tenant pool the same question, and the\n * pools are independent: one tenant's answer never informs another's. Asked in\n * series that costs `N` round trips for `N` enterprise customers, so the staff\n * list gets slower every time one is signed — the wrong direction for the\n * feature that adds them. Asked together it costs one.\n *\n * The number of CALLS is identical either way, so this buys latency and the\n * Cloud Run seconds spent waiting on it, not Auth quota. That is also why it\n * is bounded rather than an unbounded `Promise.all`: Identity Platform rate\n * limits `listUsers` per project, and firing one call per tenant simultaneously\n * would convert a latency problem into a `RESOURCE_EXHAUSTED` one at exactly\n * the customer count where the latency stopped mattering.\n */\nconst POOL_FETCH_CONCURRENCY = 8\n\n/**\n * Run `work` over every pool id, at most {@link POOL_FETCH_CONCURRENCY} at a\n * time, and return the results **in the order the ids were given**.\n *\n * Order is not cosmetic here. Callers concatenate these results into a user\n * list and then trim it to a cap, so a result order that varied with which\n * pool answered first would make the rows a staff member sees depend on\n * network timing — the same query returning different people on a refresh.\n */\nasync function mapPoolsConcurrently<T>(\n tenantIds: string[],\n work: (tenantId: string) => Promise<T>,\n): Promise<T[]> {\n const results = new Array<T>(tenantIds.length)\n let next = 0\n const workers = Array.from(\n { length: Math.min(POOL_FETCH_CONCURRENCY, tenantIds.length) },\n async () => {\n for (;;) {\n const index = next++\n if (index >= tenantIds.length) return\n results[index] = await work(tenantIds[index])\n }\n },\n )\n await Promise.all(workers)\n return results\n}\n\n/**\n * One page of users across every pool: the project pool paginates as before,\n * and tenant users are appended on the LAST page so they can never be lost\n * between cursors. Callers render `tenantId` so an SSO account is\n * recognisable — it is not editable the same way a project user is.\n */\nexport async function listUsersAcrossPools(\n pageSize: number,\n pageToken?: string,\n): Promise<PooledUserPage> {\n const page = await auth().listUsers(pageSize, pageToken)\n const users: PooledUserRecord[] = page.users.map((record) => ({\n record,\n tenantId: null,\n }))\n if (page.pageToken) {\n return {\n users,\n nextPageToken: page.pageToken,\n tenantsIncluded: false,\n tenantTruncated: [],\n }\n }\n const tenantTruncated: string[] = []\n // Every tenant asks for the same fixed page, so no pool's request depends on\n // another's answer and they can all be in flight at once. A failed pool\n // contributes no rows and does not stop the others: one enterprise tenant\n // being unreachable must not blank the whole staff list.\n //\n // The id list is read ONCE and reused. It is cached on a 60s TTL, so asking\n // twice can hand back two different lists, and pairing a page from one list\n // with an id from the other would label a user with the wrong tenant.\n const tenantIds = await listAuthTenantIds()\n const tenantPages = await mapPoolsConcurrently(tenantIds, async (tenantId) => {\n try {\n return await authForPool(tenantId).listUsers(TENANT_USER_CAP)\n } catch (error) {\n console.error(`listing users for tenant ${tenantId} failed`, error)\n return null\n }\n })\n tenantPages.forEach((tenantPage, index) => {\n if (!tenantPage) return\n const tenantId = tenantIds[index]\n users.push(...tenantPage.users.map((record) => ({ record, tenantId })))\n if (tenantPage.pageToken) tenantTruncated.push(tenantId)\n })\n return {\n // Marked only here, where every pool's rows are in hand (AGL-1962). The\n // earlier return above carries project rows alone, and a uid cannot\n // collide with itself inside one pool, so there is nothing to find there.\n users: markCrossPoolUidCollisions(users),\n nextPageToken: null,\n tenantsIncluded: true,\n tenantTruncated,\n }\n}\n\n/**\n * Every account across every pool, up to a cap — the only way to FILTER them.\n *\n * Firebase Auth's `listUsers` takes a page size and a cursor and nothing else:\n * no predicate, no ordering, no search. So a staff filter on anything but an\n * exact email, uid or phone number (which have their own O(1) lookups) can\n * only be answered by reading the accounts and matching them.\n *\n * That is an expensive read, so it happens on an ASK and never on a mount —\n * the route calls this only when a request carries a filter, and pages the\n * cheap way otherwise.\n *\n * `truncated` is true when the cap was reached, and it must be shown rather\n * than swallowed: a staff list that searched part of the directory and said\n * \"no matches\" is worse than one that admits where it stopped.\n */\nexport async function scanUsersAcrossPools(\n cap: number,\n): Promise<{\n users: PooledUserRecord[]\n truncated: boolean\n tenantTruncated: string[]\n}> {\n const users: PooledUserRecord[] = []\n let truncated = false\n let pageToken: string | undefined\n\n do {\n const remaining = cap - users.length\n if (remaining <= 0) {\n truncated = true\n break\n }\n // `listUsers` caps a page at 1000 whatever it is asked for.\n const page = await auth().listUsers(Math.min(remaining, 1000), pageToken)\n users.push(...page.users.map((record) => ({ record, tenantId: null })))\n pageToken = page.pageToken\n if (pageToken && users.length >= cap) truncated = true\n } while (pageToken && users.length < cap)\n\n const tenantTruncated: string[] = []\n const tenantIds = await listAuthTenantIds()\n /*\n * The remaining budget is fixed BEFORE any pool is asked.\n *\n * Sizing each pool's page from the running total is what forced these calls\n * into a series — pool `k`'s page size could not be known until pool `k-1`\n * had answered. Nothing needs that: a page sized to the whole remaining\n * budget is always at least as large as the shrinking one, so the first\n * `cap` rows in tenant order are the same users either way, and the pools\n * can be asked together.\n *\n * The trade is bandwidth, not quota: `listUsers` is billed per CALL, and the\n * call count is unchanged. Only when the cap actually binds does this carry\n * rows that are then trimmed.\n */\n const budget = cap - users.length\n if (budget <= 0) {\n truncated = true\n } else {\n const pageSize = Math.min(budget, TENANT_USER_CAP)\n const pages = await mapPoolsConcurrently(tenantIds, async (tenantId) => {\n try {\n return await authForPool(tenantId).listUsers(pageSize)\n } catch (error) {\n console.error(`listing users for tenant ${tenantId} failed`, error)\n return null\n }\n })\n pages.forEach((page, index) => {\n if (!page) return\n const tenantId = tenantIds[index]\n const room = cap - users.length\n const rows = page.users.map((record) => ({ record, tenantId }))\n // A pool whose rows will not fit is still a pool that was cut short, and\n // it has to say so. A scan that silently dropped one would answer \"no\n // such account\" for everyone in it — the exact failure this module\n // exists to prevent, wearing the shape of a complete result.\n if (rows.length > room) {\n truncated = true\n tenantTruncated.push(tenantId)\n } else if (page.pageToken) {\n tenantTruncated.push(tenantId)\n }\n if (room > 0) users.push(...rows.slice(0, room))\n })\n }\n\n return {\n users: markCrossPoolUidCollisions(users),\n truncated,\n tenantTruncated,\n }\n}\n\n/** Where a claim mutation landed, so a caller can audit the POOL as well. */\nexport interface StaffClaimWrite {\n uid: string\n /** The pool the claim was written to; null = the project pool. */\n tenantId: string | null\n email: string | null\n claims: Record<string, unknown>\n}\n\n/**\n * Write custom claims to the pool the uid ACTUALLY lives in (AGL-1993).\n *\n * The hazard this exists to remove: `setCustomUserClaims` is per-pool, and a\n * grant aimed at the wrong pool does not error — on the project pool it\n * either throws `user-not-found` (loud, fine) or, when a phantom shadow record\n * shares the uid, silently succeeds against the WRONG record. That is not\n * hypothetical: on 2026-08-18 a staff account's uid existed in both the\n * project pool and `aglyn-org-y5v14` at once. That phantom won every lookup\n * until AGL-2005 made {@link findUserByUidAcrossPools} rank an IDENTIFIED\n * record above an unidentified one rather than taking the first pool to\n * answer.\n *\n * So resolution and mutation are bound together here rather than left to each\n * caller to pair correctly, and the resolved pool is RETURNED so the audit row\n * can record which pool was written — \"granted staff to uid X\" is not a\n * complete record when a uid can name two accounts.\n *\n * Never assumes a pool. Staff may live in the project pool, in Aglyn's own\n * tenant, or in a CUSTOMER org's tenant, and hard-coding any of them would\n * silently fail the moment a third exists.\n */\nexport async function setClaimsInOwningPool(\n uid: string,\n claims: Record<string, unknown>,\n): Promise<StaffClaimWrite | null> {\n const found = await findUserByUidAcrossPools(uid)\n if (!found) return null\n await authForPool(found.tenantId).setCustomUserClaims(uid, claims)\n return {\n uid,\n tenantId: found.tenantId,\n email: found.record.email ?? null,\n claims,\n }\n}\n\n/**\n * Every uid carrying the `staff` claim, across all pools. Staff is a custom\n * claim, which is not queryable, so this scans — the caller caches it.\n */\nexport async function listStaffUidsAcrossPools(): Promise<string[]> {\n /*\n * Paging WITHIN a pool stays serial — each page's cursor comes out of the\n * one before it, so there is nothing to overlap. Across pools there is: the\n * pools are independent, and a staff notification waiting on them one after\n * another gets slower with every enterprise customer signed.\n *\n * Each scan collects into its own array rather than a shared one, so the\n * result is ordered by POOL rather than by which pool happened to answer\n * first. A caller diffing this list against a stored copy would otherwise\n * see phantom changes on every run.\n */\n const scan = async (pool: BaseAuth): Promise<string[]> => {\n const found: string[] = []\n let pageToken: string | undefined\n do {\n const page = await pool.listUsers(1000, pageToken)\n for (const record of page.users) {\n if (record.customClaims?.['staff']) found.push(record.uid)\n }\n pageToken = page.pageToken\n } while (pageToken)\n return found\n }\n const tenantIds = await listAuthTenantIds()\n const [projectUids, tenantUidLists] = await Promise.all([\n scan(auth()),\n mapPoolsConcurrently(tenantIds, async (tenantId) => {\n try {\n return await scan(authForPool(tenantId))\n } catch (error) {\n console.error(`staff scan for tenant ${tenantId} failed`, error)\n return [] as string[]\n }\n }),\n ])\n return [...projectUids, ...tenantUidLists.flat()]\n}\n"],"names":["firebaseAdmin","auth","app","isIdentifiedUserRecord","record","Boolean","email","providerData","length","identityStrength","score","phoneNumber","displayName","Object","keys","customClaims","collapseCrossPoolUidRows","users","marked","markCrossPoolUidCollisions","winnerByUid","Map","user","incumbent","get","uid","set","emitted","Set","collapsed","has","add","push","poolsByUid","pools","tenantId","map","others","filter","_","index","indexOf","uidAlsoInPools","tenantCache","TENANT_CACHE_MS","listAuthTenantIds","now","Date","at","ids","pageToken","page","tenantManager","listTenants","tenants","tenant","error","console","resetAuthTenantCache","authForPool","authForTenant","findUserByEmailAcrossPools","address","trim","toLowerCase","getUserByEmail","findUserByUidAcrossPools","matches","getUser","TENANT_USER_CAP","POOL_FETCH_CONCURRENCY","mapPoolsConcurrently","tenantIds","work","results","Array","next","workers","from","Math","min","Promise","all","listUsersAcrossPools","pageSize","listUsers","nextPageToken","tenantsIncluded","tenantTruncated","tenantPages","forEach","tenantPage","scanUsersAcrossPools","cap","truncated","remaining","budget","pages","room","rows","slice","setClaimsInOwningPool","claims","found","setCustomUserClaims","listStaffUidsAcrossPools","scan","pool","projectUids","tenantUidLists","flat"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;CAmBC;AAGD,OAAOA,mBAAmB,sBAAkB;AAE5C,MAAMC,OAAO,IAAMD,cAAcE,GAAG,GAAGD,IAAI;AA2B3C;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASE,uBAAuBC,MAAkB;;QAEtBA;IADjC,IAAI,CAACA,QAAQ,OAAO;IACpB,OAAOC,QAAQD,OAAOE,KAAK,KAAK,UAACF,uBAAAA,OAAOG,YAAY,qBAAnBH,qBAAqBI,MAAM,mBAAI,KAAK;AACvE;AAEA;;;;;;CAMC,GACD,OAAO,SAASC,iBAAiBL,MAAkB;cAOjCA;QAHXA;IAHL,IAAI,CAACA,QAAQ,OAAO;IACpB,IAAIM,QAAQ;IACZ,IAAIN,OAAOE,KAAK,EAAEI,SAAS;IAC3B,IAAI,UAACN,uBAAAA,OAAOG,YAAY,qBAAnBH,qBAAqBI,MAAM,mBAAI,KAAK,GAAGE,SAAS;IACrD,IAAIN,OAAOO,WAAW,EAAED,SAAS;IACjC,IAAIN,OAAOQ,WAAW,EAAEF,SAAS;IACjC,IAAIG,OAAOC,IAAI,EAACV,uBAAAA,OAAOW,YAAY,YAAnBX,uBAAuB,CAAC,GAAGI,MAAM,GAAG,GAAGE,SAAS;IAChE,OAAOA;AACT;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GACD,OAAO,SAASM,yBACdC,KAAyB;IAEzB,2EAA2E;IAC3E,oEAAoE;IACpE,MAAMC,SAASC,2BAA2BF;IAC1C,MAAMG,cAAc,IAAIC;IACxB,KAAK,MAAMC,QAAQJ,OAAQ;QACzB,MAAMK,YAAYH,YAAYI,GAAG,CAACF,KAAKlB,MAAM,CAACqB,GAAG;QACjD,IACE,CAACF,aACDd,iBAAiBa,KAAKlB,MAAM,IAAIK,iBAAiBc,UAAUnB,MAAM,GACjE;YACAgB,YAAYM,GAAG,CAACJ,KAAKlB,MAAM,CAACqB,GAAG,EAAEH;QACnC;IACF;IACA,MAAMK,UAAU,IAAIC;IACpB,MAAMC,YAAgC,EAAE;IACxC,KAAK,MAAMP,QAAQJ,OAAQ;YAIVE;QAHf,MAAMK,MAAMH,KAAKlB,MAAM,CAACqB,GAAG;QAC3B,IAAIE,QAAQG,GAAG,CAACL,MAAM;QACtBE,QAAQI,GAAG,CAACN;QACZI,UAAUG,IAAI,EAACZ,mBAAAA,YAAYI,GAAG,CAACC,gBAAhBL,mBAAwBE;IACzC;IACA,OAAOO;AACT;AAEA;;;;;;CAMC,GACD,OAAO,SAASV,2BACdF,KAAyB;IAEzB,MAAMgB,aAAa,IAAIZ;IACvB,KAAK,MAAMC,QAAQL,MAAO;YACVgB;QAAd,MAAMC,SAAQD,kBAAAA,WAAWT,GAAG,CAACF,KAAKlB,MAAM,CAACqB,GAAG,aAA9BQ,kBAAmC,EAAE;QACnDC,MAAMF,IAAI,CAACV,KAAKa,QAAQ;QACxBF,WAAWP,GAAG,CAACJ,KAAKlB,MAAM,CAACqB,GAAG,EAAES;IAClC;IACA,OAAOjB,MAAMmB,GAAG,CAAC,CAACd;YACFW;QAAd,MAAMC,SAAQD,kBAAAA,WAAWT,GAAG,CAACF,KAAKlB,MAAM,CAACqB,GAAG,aAA9BQ,kBAAmC,EAAE;QACnD,IAAIC,MAAM1B,MAAM,GAAG,GAAG,OAAOc;QAC7B,oEAAoE;QACpE,uEAAuE;QACvE,wEAAwE;QACxE,MAAMe,SAASH,MAAMI,MAAM,CAAC,CAACC,GAAGC,QAAUA,UAAUN,MAAMO,OAAO,CAACnB,KAAKa,QAAQ;QAC/E,OAAO,aAAKb;YAAMoB,gBAAgBL;;IACpC;AACF;AAEA;;;;;CAKC,GACD,IAAIM,cAAoD;AACxD,MAAMC,kBAAkB;AAExB,OAAO,eAAeC;IACpB,MAAMC,MAAMC,KAAKD,GAAG;IACpB,IAAIH,eAAeG,MAAMH,YAAYK,EAAE,GAAGJ,iBAAiB;QACzD,OAAOD,YAAYM,GAAG;IACxB;IACA,IAAI;QACF,MAAMA,MAAgB,EAAE;QACxB,IAAIC;QACJ,GAAG;YACD,MAAMC,OAAO,MAAMlD,OAAOmD,aAAa,GAAGC,WAAW,CAAC,KAAKH;YAC3DD,IAAIjB,IAAI,IAAImB,KAAKG,OAAO,CAAClB,GAAG,CAAC,CAACmB,SAAWA,OAAOpB,QAAQ;YACxDe,YAAYC,KAAKD,SAAS;QAC5B,QAASA,UAAU;QACnBP,cAAc;YAAEM;YAAKD,IAAIF;QAAI;QAC7B,OAAOG;IACT,EAAE,OAAOO,OAAO;;QACdC,QAAQD,KAAK,CAAC,uDAAuDA;QACrE,eAAOb,+BAAAA,YAAaM,GAAG,mBAAI,EAAE;IAC/B;AACF;AAEA,0EAA0E,GAC1E,OAAO,SAASS;IACdf,cAAc;AAChB;AAEA,6EAA6E,GAC7E,OAAO,SAASgB,YAAYxB,QAAmC;IAC7D,OAAOA,WACHlC,OAAOmD,aAAa,GAAGQ,aAAa,CAACzB,YACrClC;AACN;AAEA;;;;;;;CAOC,GACD,OAAO,eAAe4D,2BACpBvD,KAAa;IAEb,MAAMwD,UAAUxD,MAAMyD,IAAI,GAAGC,WAAW;IACxC,IAAI,CAACF,SAAS,OAAO;IACrB,IAAI;QACF,OAAO;YAAE1D,QAAQ,MAAMH,OAAOgE,cAAc,CAACH;YAAU3B,UAAU;QAAK;IACxE,EAAE,eAAM;IACN,yDAAyD;IAC3D;IACA,KAAK,MAAMA,YAAY,CAAA,MAAMU,mBAAkB,EAAG;QAChD,IAAI;YACF,MAAMzC,SAAS,MAAMuD,YAAYxB,UAAU8B,cAAc,CAACH;YAC1D,OAAO;gBAAE1D;gBAAQ+B;YAAS;QAC5B,EAAE,eAAM;QACN,oCAAoC;QACtC;IACF;IACA,OAAO;AACT;AAEA;;;;;;;;;;;;;;;;;;;;;;;;CAwBC,GACD,OAAO,eAAe+B,yBACpBzC,GAAW;QA4BJT;IA1BP,IAAI,CAACS,KAAK,OAAO;IACjB,MAAM0C,UAA8B,EAAE;IACtC,IAAI;QACF,MAAM/D,SAAS,MAAMH,OAAOmE,OAAO,CAAC3C;QACpC,IAAItB,uBAAuBC,SAAS,OAAO;YAAEA;YAAQ+B,UAAU;QAAK;QACpE,wEAAwE;QACxE,yEAAyE;QACzE,gEAAgE;QAChEgC,QAAQnC,IAAI,CAAC;YAAE5B;YAAQ+B,UAAU;QAAK;IACxC,EAAE,eAAM;IACN,2BAA2B;IAC7B;IACA,KAAK,MAAMA,YAAY,CAAA,MAAMU,mBAAkB,EAAG;QAChD,IAAI;YACF,MAAMzC,SAAS,MAAMuD,YAAYxB,UAAUiC,OAAO,CAAC3C;YACnD0C,QAAQnC,IAAI,CAAC;gBAAE5B;gBAAQ+B;YAAS;YAChC,mEAAmE;YACnE,qEAAqE;YACrE,uEAAuE;YACvE,kEAAkE;YAClE,IAAIhC,uBAAuBC,SAAS;QACtC,EAAE,eAAM;QACN,gBAAgB;QAClB;IACF;IACA,IAAI,CAAC+D,QAAQ3D,MAAM,EAAE,OAAO;IAC5B,QAAOQ,6BAAAA,yBAAyBmD,QAAQ,CAAC,EAAE,YAApCnD,6BAAwC;AACjD;AAkBA,2EAA2E,GAC3E,MAAMqD,kBAAkB;AAExB;;;;;;;;;;;;;;;CAeC,GACD,MAAMC,yBAAyB;AAE/B;;;;;;;;CAQC,GACD,eAAeC,qBACbC,SAAmB,EACnBC,IAAsC;IAEtC,MAAMC,UAAU,IAAIC,MAASH,UAAUhE,MAAM;IAC7C,IAAIoE,OAAO;IACX,MAAMC,UAAUF,MAAMG,IAAI,CACxB;QAAEtE,QAAQuE,KAAKC,GAAG,CAACV,wBAAwBE,UAAUhE,MAAM;IAAE,GAC7D;QACE,OAAS;YACP,MAAMgC,QAAQoC;YACd,IAAIpC,SAASgC,UAAUhE,MAAM,EAAE;YAC/BkE,OAAO,CAAClC,MAAM,GAAG,MAAMiC,KAAKD,SAAS,CAAChC,MAAM;QAC9C;IACF;IAEF,MAAMyC,QAAQC,GAAG,CAACL;IAClB,OAAOH;AACT;AAEA;;;;;CAKC,GACD,OAAO,eAAeS,qBACpBC,QAAgB,EAChBlC,SAAkB;IAElB,MAAMC,OAAO,MAAMlD,OAAOoF,SAAS,CAACD,UAAUlC;IAC9C,MAAMjC,QAA4BkC,KAAKlC,KAAK,CAACmB,GAAG,CAAC,CAAChC,SAAY,CAAA;YAC5DA;YACA+B,UAAU;QACZ,CAAA;IACA,IAAIgB,KAAKD,SAAS,EAAE;QAClB,OAAO;YACLjC;YACAqE,eAAenC,KAAKD,SAAS;YAC7BqC,iBAAiB;YACjBC,iBAAiB,EAAE;QACrB;IACF;IACA,MAAMA,kBAA4B,EAAE;IACpC,6EAA6E;IAC7E,wEAAwE;IACxE,0EAA0E;IAC1E,yDAAyD;IACzD,EAAE;IACF,4EAA4E;IAC5E,4EAA4E;IAC5E,sEAAsE;IACtE,MAAMhB,YAAY,MAAM3B;IACxB,MAAM4C,cAAc,MAAMlB,qBAAqBC,WAAW,OAAOrC;QAC/D,IAAI;YACF,OAAO,MAAMwB,YAAYxB,UAAUkD,SAAS,CAAChB;QAC/C,EAAE,OAAOb,OAAO;YACdC,QAAQD,KAAK,CAAC,CAAC,yBAAyB,EAAErB,SAAS,OAAO,CAAC,EAAEqB;YAC7D,OAAO;QACT;IACF;IACAiC,YAAYC,OAAO,CAAC,CAACC,YAAYnD;QAC/B,IAAI,CAACmD,YAAY;QACjB,MAAMxD,WAAWqC,SAAS,CAAChC,MAAM;QACjCvB,MAAMe,IAAI,IAAI2D,WAAW1E,KAAK,CAACmB,GAAG,CAAC,CAAChC,SAAY,CAAA;gBAAEA;gBAAQ+B;YAAS,CAAA;QACnE,IAAIwD,WAAWzC,SAAS,EAAEsC,gBAAgBxD,IAAI,CAACG;IACjD;IACA,OAAO;QACL,wEAAwE;QACxE,oEAAoE;QACpE,0EAA0E;QAC1ElB,OAAOE,2BAA2BF;QAClCqE,eAAe;QACfC,iBAAiB;QACjBC;IACF;AACF;AAEA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,eAAeI,qBACpBC,GAAW;IAMX,MAAM5E,QAA4B,EAAE;IACpC,IAAI6E,YAAY;IAChB,IAAI5C;IAEJ,GAAG;QACD,MAAM6C,YAAYF,MAAM5E,MAAMT,MAAM;QACpC,IAAIuF,aAAa,GAAG;YAClBD,YAAY;YACZ;QACF;QACA,4DAA4D;QAC5D,MAAM3C,OAAO,MAAMlD,OAAOoF,SAAS,CAACN,KAAKC,GAAG,CAACe,WAAW,OAAO7C;QAC/DjC,MAAMe,IAAI,IAAImB,KAAKlC,KAAK,CAACmB,GAAG,CAAC,CAAChC,SAAY,CAAA;gBAAEA;gBAAQ+B,UAAU;YAAK,CAAA;QACnEe,YAAYC,KAAKD,SAAS;QAC1B,IAAIA,aAAajC,MAAMT,MAAM,IAAIqF,KAAKC,YAAY;IACpD,QAAS5C,aAAajC,MAAMT,MAAM,GAAGqF,IAAI;IAEzC,MAAML,kBAA4B,EAAE;IACpC,MAAMhB,YAAY,MAAM3B;IACxB;;;;;;;;;;;;;GAaC,GACD,MAAMmD,SAASH,MAAM5E,MAAMT,MAAM;IACjC,IAAIwF,UAAU,GAAG;QACfF,YAAY;IACd,OAAO;QACL,MAAMV,WAAWL,KAAKC,GAAG,CAACgB,QAAQ3B;QAClC,MAAM4B,QAAQ,MAAM1B,qBAAqBC,WAAW,OAAOrC;YACzD,IAAI;gBACF,OAAO,MAAMwB,YAAYxB,UAAUkD,SAAS,CAACD;YAC/C,EAAE,OAAO5B,OAAO;gBACdC,QAAQD,KAAK,CAAC,CAAC,yBAAyB,EAAErB,SAAS,OAAO,CAAC,EAAEqB;gBAC7D,OAAO;YACT;QACF;QACAyC,MAAMP,OAAO,CAAC,CAACvC,MAAMX;YACnB,IAAI,CAACW,MAAM;YACX,MAAMhB,WAAWqC,SAAS,CAAChC,MAAM;YACjC,MAAM0D,OAAOL,MAAM5E,MAAMT,MAAM;YAC/B,MAAM2F,OAAOhD,KAAKlC,KAAK,CAACmB,GAAG,CAAC,CAAChC,SAAY,CAAA;oBAAEA;oBAAQ+B;gBAAS,CAAA;YAC5D,yEAAyE;YACzE,sEAAsE;YACtE,mEAAmE;YACnE,6DAA6D;YAC7D,IAAIgE,KAAK3F,MAAM,GAAG0F,MAAM;gBACtBJ,YAAY;gBACZN,gBAAgBxD,IAAI,CAACG;YACvB,OAAO,IAAIgB,KAAKD,SAAS,EAAE;gBACzBsC,gBAAgBxD,IAAI,CAACG;YACvB;YACA,IAAI+D,OAAO,GAAGjF,MAAMe,IAAI,IAAImE,KAAKC,KAAK,CAAC,GAAGF;QAC5C;IACF;IAEA,OAAO;QACLjF,OAAOE,2BAA2BF;QAClC6E;QACAN;IACF;AACF;AAWA;;;;;;;;;;;;;;;;;;;;;CAqBC,GACD,OAAO,eAAea,sBACpB5E,GAAW,EACX6E,MAA+B;QAQtBC;IANT,MAAMA,QAAQ,MAAMrC,yBAAyBzC;IAC7C,IAAI,CAAC8E,OAAO,OAAO;IACnB,MAAM5C,YAAY4C,MAAMpE,QAAQ,EAAEqE,mBAAmB,CAAC/E,KAAK6E;IAC3D,OAAO;QACL7E;QACAU,UAAUoE,MAAMpE,QAAQ;QACxB7B,KAAK,GAAEiG,sBAAAA,MAAMnG,MAAM,CAACE,KAAK,YAAlBiG,sBAAsB;QAC7BD;IACF;AACF;AAEA;;;CAGC,GACD,OAAO,eAAeG;IACpB;;;;;;;;;;GAUC,GACD,MAAMC,OAAO,OAAOC;QAClB,MAAMJ,QAAkB,EAAE;QAC1B,IAAIrD;QACJ,GAAG;YACD,MAAMC,OAAO,MAAMwD,KAAKtB,SAAS,CAAC,MAAMnC;YACxC,KAAK,MAAM9C,UAAU+C,KAAKlC,KAAK,CAAE;oBAC3Bb;gBAAJ,KAAIA,uBAAAA,OAAOW,YAAY,qBAAnBX,oBAAqB,CAAC,QAAQ,EAAEmG,MAAMvE,IAAI,CAAC5B,OAAOqB,GAAG;YAC3D;YACAyB,YAAYC,KAAKD,SAAS;QAC5B,QAASA,UAAU;QACnB,OAAOqD;IACT;IACA,MAAM/B,YAAY,MAAM3B;IACxB,MAAM,CAAC+D,aAAaC,eAAe,GAAG,MAAM5B,QAAQC,GAAG,CAAC;QACtDwB,KAAKzG;QACLsE,qBAAqBC,WAAW,OAAOrC;YACrC,IAAI;gBACF,OAAO,MAAMuE,KAAK/C,YAAYxB;YAChC,EAAE,OAAOqB,OAAO;gBACdC,QAAQD,KAAK,CAAC,CAAC,sBAAsB,EAAErB,SAAS,OAAO,CAAC,EAAEqB;gBAC1D,OAAO,EAAE;YACX;QACF;KACD;IACD,OAAO;WAAIoD;WAAgBC,eAAeC,IAAI;KAAG;AACnD"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/auth-pools.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Cross-pool Firebase Auth lookups (AGL-1122).\n *\n * Enterprise SSO (AGL-1101) signs users into a per-org **GCIP tenant**, and a\n * tenant has its OWN user pool. A project-level `getUserByEmail` /\n * `listUsers` does not see those users at all — measured on aglyn-main:\n * the owner of aglyn-org returned `auth/user-not-found` at project level and\n * was present only under tenant `aglyn-org-y5v14`.\n *\n * That silently broke five separate things: staff could not find, view,\n * disable or grant staff to an SSO user; adding one to an org or a site by\n * email 404'd into \"send them an invite\" for an account that already exists;\n * and `notifyStaff` skipped SSO staff entirely. Fixing those one route at a\n * time would guarantee the next new route reintroduces the bug, so every\n * caller goes through here instead.\n *\n * Custom claims are per-pool too: `setCustomUserClaims` on the project pool\n * cannot grant staff to a tenant user. `authForPool` hands back the right\n * `BaseAuth` so a mutation lands in the pool the user actually lives in.\n */\n\nimport type { BaseAuth, UserRecord } from 'firebase-admin/auth'\nimport firebaseAdmin from './firebase-admin'\n\nconst auth = () => firebaseAdmin.app().auth()\n\n/** A user plus the pool they were found in (`null` = the project pool). */\nexport interface PooledUserRecord {\n record: UserRecord\n /** GCIP tenant id, or null when the user is in the project-level pool. */\n tenantId: string | null\n /**\n * Other pools that ALSO hold this uid (AGL-1962). Normally absent: a uid is\n * unique within a pool, and the pools are meant to hold different people.\n *\n * It is populated when the same uid turns up in more than one pool, which\n * means something minted a custom token for one pool's uid against another\n * pool — `signInWithCustomToken` CREATES the account when the uid is absent,\n * so the mistake manufactures an empty shadow account rather than failing.\n * Measured on production: `QQ7fixtureUid0000000000000001` existed in both the\n * project pool and `aglyn-org-y5v14`, the project copy created by\n * `/api/presence/token` minting an SSO user's tenant uid at project level.\n *\n * This is the marker, not a deletion: `listUsersAcrossPools` still returns\n * every record, and `collapseCrossPoolUidRows` — which is what a display\n * uses — keeps this field on the row it keeps, so a collision is still\n * legible after the rows are merged (AGL-2005).\n */\n uidAlsoInPools?: (string | null)[]\n}\n\n/**\n * Does this record show that a human ever authenticated as it? (AGL-2005)\n *\n * An address or a provider entry is the evidence. `signInWithCustomToken`\n * mints an account out of nothing when the uid is absent from the pool the\n * token was minted in, and the record it manufactures has neither: measured\n * on production, a project-pool twin of a tenant uid had `email: null` and\n * `providerData: []` while the real SSO record carried both an address and\n * `saml.aglyn-workspace`.\n *\n * `displayName` and custom claims deliberately do NOT count. Both can be\n * written onto any record after the fact — `updateProfile` and `grantStaff`\n * in the staff console do exactly that — so a shadow that a staff action had\n * already landed on would start qualifying as an identity and go on winning\n * the lookups it should be losing. The bar has to be a signal the forgery\n * cannot acquire by being acted upon.\n */\nexport function isIdentifiedUserRecord(record: UserRecord): boolean {\n if (!record) return false\n return Boolean(record.email) || (record.providerData?.length ?? 0) > 0\n}\n\n/**\n * How strongly a record identifies its human, for choosing between two that\n * share a uid (AGL-2005). Ordering only — the numbers have no other meaning.\n *\n * Email and provider data lead because they are what `isIdentifiedUserRecord`\n * trusts; the rest break ties between records that both qualify.\n */\nexport function identityStrength(record: UserRecord): number {\n if (!record) return 0\n let score = 0\n if (record.email) score += 8\n if ((record.providerData?.length ?? 0) > 0) score += 4\n if (record.phoneNumber) score += 2\n if (record.displayName) score += 1\n if (Object.keys(record.customClaims ?? {}).length > 0) score += 1\n return score\n}\n\n/**\n * Collapse rows sharing a uid to ONE row apiece (AGL-2005).\n *\n * **decided:** \"We still have two users list in this list with the\n * same uid but one without an email attached, this needs fixed we should only\n * see one user, even if they are sso.\"\n *\n * AGL-1962 deliberately refused to merge, on the grounds that collapsing two\n * accounts could hide an identity split. That reasoning still stands for two\n * DISTINCT accounts — which is why this keys on **uid** and never on email.\n * Two people who happen to share an address are two accounts with two uids\n * and stay two rows; merging them on email would delete a real user from the\n * staff console, a worse bug than the one being fixed and an invisible one.\n *\n * A repeated uid is different in kind: a uid is unique WITHIN a pool, so the\n * same uid in two pools is not two people, it is one person plus an artifact\n * of a cross-pool custom-token mint. Those merge.\n *\n * Two rules make the merge safe:\n *\n * - **The identified record wins.** Ranked by `identityStrength`, so an\n * emailless, providerless artifact can never be the row staff see — the\n * exact inversion that let the shadow win every uid lookup.\n * - **The collision is not discarded.** `markCrossPoolUidCollisions` runs\n * here rather than being left to the caller, so the surviving row always\n * carries `uidAlsoInPools` naming the pools that were folded into it. One\n * row, and it still says it was two.\n *\n * The survivor takes the FIRST occurrence's position so list ordering does\n * not shuffle, and ties keep the earlier row so the result is deterministic.\n */\nexport function collapseCrossPoolUidRows(\n users: PooledUserRecord[],\n): PooledUserRecord[] {\n // Marked here, not by the caller. A caller that forgot would silently turn\n // this into the cover-up the merge is explicitly not allowed to be.\n const marked = markCrossPoolUidCollisions(users)\n const winnerByUid = new Map<string, PooledUserRecord>()\n for (const user of marked) {\n const incumbent = winnerByUid.get(user.record.uid)\n if (\n !incumbent ||\n identityStrength(user.record) > identityStrength(incumbent.record)\n ) {\n winnerByUid.set(user.record.uid, user)\n }\n }\n const emitted = new Set<string>()\n const collapsed: PooledUserRecord[] = []\n for (const user of marked) {\n const uid = user.record.uid\n if (emitted.has(uid)) continue\n emitted.add(uid)\n collapsed.push(winnerByUid.get(uid) ?? user)\n }\n return collapsed\n}\n\n/**\n * Flag every uid that appears in more than one pool, in place of hiding it.\n *\n * Pure and total, so a spec can drive it without Firebase. Callers must pass\n * the rows they intend to show together — a collision it cannot see is one\n * it cannot report.\n */\nexport function markCrossPoolUidCollisions(\n users: PooledUserRecord[],\n): PooledUserRecord[] {\n const poolsByUid = new Map<string, (string | null)[]>()\n for (const user of users) {\n const pools = poolsByUid.get(user.record.uid) ?? []\n pools.push(user.tenantId)\n poolsByUid.set(user.record.uid, pools)\n }\n return users.map((user) => {\n const pools = poolsByUid.get(user.record.uid) ?? []\n if (pools.length < 2) return user\n // Everyone holding this uid EXCEPT this row's own pool. Compared by\n // index, not by value: two rows from the same pool would be a Firebase\n // impossibility, but filtering by value would silently drop a real one.\n const others = pools.filter((_, index) => index !== pools.indexOf(user.tenantId))\n return { ...user, uidAlsoInPools: others }\n })\n}\n\n/**\n * Every GCIP tenant on the project. Cached briefly: this is asked once per\n * lookup and the set changes only when an enterprise org is onboarded.\n * Fails soft to an empty list — a tenant-listing outage must degrade to\n * today's project-only behaviour rather than break sign-in-adjacent routes.\n */\nlet tenantCache: { ids: string[]; at: number } | null = null\nconst TENANT_CACHE_MS = 60_000\n\nexport async function listAuthTenantIds(): Promise<string[]> {\n const now = Date.now()\n if (tenantCache && now - tenantCache.at < TENANT_CACHE_MS) {\n return tenantCache.ids\n }\n try {\n const ids: string[] = []\n let pageToken: string | undefined\n do {\n const page = await auth().tenantManager().listTenants(100, pageToken)\n ids.push(...page.tenants.map((tenant) => tenant.tenantId))\n pageToken = page.pageToken\n } while (pageToken)\n tenantCache = { ids, at: now }\n return ids\n } catch (error) {\n console.error('tenant listing failed; falling back to project pool', error)\n return tenantCache?.ids ?? []\n }\n}\n\n/** Test seam — drops the tenant cache so a spec can vary the tenant set. */\nexport function resetAuthTenantCache(): void {\n tenantCache = null\n}\n\n/** The `BaseAuth` for a pool: the project pool for null, else the tenant's. */\nexport function authForPool(tenantId: string | null | undefined): BaseAuth {\n return tenantId\n ? auth().tenantManager().authForTenant(tenantId)\n : auth()\n}\n\n/**\n * Find a user by email across the project pool and every GCIP tenant.\n *\n * The project pool is tried first — it holds every non-SSO user, which is\n * almost all of them, so the common case stays a single call. Returns null\n * when no pool has the address, which is the only honest answer for \"this\n * person has no Aglyn account\".\n */\nexport async function findUserByEmailAcrossPools(\n email: string,\n): Promise<PooledUserRecord | null> {\n const address = email.trim().toLowerCase()\n if (!address) return null\n try {\n return { record: await auth().getUserByEmail(address), tenantId: null }\n } catch {\n // Not in the project pool — fall through to the tenants.\n }\n for (const tenantId of await listAuthTenantIds()) {\n try {\n const record = await authForPool(tenantId).getUserByEmail(address)\n return { record, tenantId }\n } catch {\n // Not in this tenant; keep looking.\n }\n }\n return null\n}\n\n/**\n * Find a user by uid across the project pool and every GCIP tenant. Same\n * shape as the email lookup — a uid is only unique WITHIN a pool, so the\n * caller needs to know which pool answered before it mutates anything.\n *\n * **First pool to answer is NOT good enough** (AGL-2005). This used to return\n * the project pool's record the moment it existed, which is how a forged\n * emailless twin won every uid lookup for a real SSO user: `grantStaff`,\n * `disable`, `setRole`, `updateProfile`, `revokeRefreshTokens`, impersonate,\n * usage-email, resolve-people, org-lockdown and erasure all landed on a\n * record nobody can sign in as. Two of those were caught having happened —\n * a `revokeRefreshTokens` dated 2026-08-14 sat on the twin while the real\n * account's `tokensValidAfterTime` never moved, so \"sign out everywhere\" did\n * nothing at all.\n *\n * So a pool's answer is accepted only when the record it returns actually\n * identifies someone, and the winner is chosen by the SAME rule the staff\n * list collapses rows with. That equivalence is the point: the row staff\n * click is the record the action mutates, by construction rather than by\n * coincidence.\n *\n * The fast path survives intact — a project account with an address or a\n * provider is returned without any tenant call, which is nearly every\n * account. Only a record carrying no identity at all pays for the sweep.\n */\nexport async function findUserByUidAcrossPools(\n uid: string,\n): Promise<PooledUserRecord | null> {\n if (!uid) return null\n const matches: PooledUserRecord[] = []\n try {\n const record = await auth().getUser(uid)\n if (isIdentifiedUserRecord(record)) return { record, tenantId: null }\n // Held, not returned. It is still the answer if nothing better exists —\n // an anonymous or half-created account legitimately looks like this, and\n // refusing it here would break every caller that handles those.\n matches.push({ record, tenantId: null })\n } catch {\n // Not in the project pool.\n }\n for (const tenantId of await listAuthTenantIds()) {\n try {\n const record = await authForPool(tenantId).getUser(uid)\n matches.push({ record, tenantId })\n // Stop at the first pool that knows who this is. Scanning the rest\n // would cost a call per enterprise tenant on a path that runs during\n // sign-in and billing, and a uid identified in two tenant pools is not\n // a shape Firebase can produce — tenant uids are minted per pool.\n if (isIdentifiedUserRecord(record)) break\n } catch {\n // Keep looking.\n }\n }\n if (!matches.length) return null\n return collapseCrossPoolUidRows(matches)[0] ?? null\n}\n\nexport interface PooledUserPage {\n users: PooledUserRecord[]\n /** Project-pool cursor; null when the project pool is exhausted. */\n nextPageToken: string | null\n /**\n * True once the project pool is exhausted and the tenant users have been\n * appended. Tenant pools are NOT paginated here — enterprise SSO pools are\n * small, and silently dropping their tail would recreate exactly the\n * invisibility this module exists to fix. `tenantTruncated` says when that\n * assumption stopped holding rather than letting it fail quietly.\n */\n tenantsIncluded: boolean\n /** Tenants whose users hit the cap and were cut short. */\n tenantTruncated: string[]\n}\n\n/** How many tenant users a single page will carry before reporting a cut. */\nconst TENANT_USER_CAP = 500\n\n/**\n * How many pools may have a call in flight at once.\n *\n * Every sweep below asks each GCIP tenant pool the same question, and the\n * pools are independent: one tenant's answer never informs another's. Asked in\n * series that costs `N` round trips for `N` enterprise customers, so the staff\n * list gets slower every time one is signed — the wrong direction for the\n * feature that adds them. Asked together it costs one.\n *\n * The number of CALLS is identical either way, so this buys latency and the\n * Cloud Run seconds spent waiting on it, not Auth quota. That is also why it\n * is bounded rather than an unbounded `Promise.all`: Identity Platform rate\n * limits `listUsers` per project, and firing one call per tenant simultaneously\n * would convert a latency problem into a `RESOURCE_EXHAUSTED` one at exactly\n * the customer count where the latency stopped mattering.\n */\nconst POOL_FETCH_CONCURRENCY = 8\n\n/**\n * Run `work` over every pool id, at most {@link POOL_FETCH_CONCURRENCY} at a\n * time, and return the results **in the order the ids were given**.\n *\n * Order is not cosmetic here. Callers concatenate these results into a user\n * list and then trim it to a cap, so a result order that varied with which\n * pool answered first would make the rows a staff member sees depend on\n * network timing — the same query returning different people on a refresh.\n */\nasync function mapPoolsConcurrently<T>(\n tenantIds: string[],\n work: (tenantId: string) => Promise<T>,\n): Promise<T[]> {\n const results = new Array<T>(tenantIds.length)\n let next = 0\n const workers = Array.from(\n { length: Math.min(POOL_FETCH_CONCURRENCY, tenantIds.length) },\n async () => {\n for (;;) {\n const index = next++\n if (index >= tenantIds.length) return\n results[index] = await work(tenantIds[index])\n }\n },\n )\n await Promise.all(workers)\n return results\n}\n\n/**\n * One page of users across every pool: the project pool paginates as before,\n * and tenant users are appended on the LAST page so they can never be lost\n * between cursors. Callers render `tenantId` so an SSO account is\n * recognisable — it is not editable the same way a project user is.\n */\nexport async function listUsersAcrossPools(\n pageSize: number,\n pageToken?: string,\n): Promise<PooledUserPage> {\n const page = await auth().listUsers(pageSize, pageToken)\n const users: PooledUserRecord[] = page.users.map((record) => ({\n record,\n tenantId: null,\n }))\n if (page.pageToken) {\n return {\n users,\n nextPageToken: page.pageToken,\n tenantsIncluded: false,\n tenantTruncated: [],\n }\n }\n const tenantTruncated: string[] = []\n // Every tenant asks for the same fixed page, so no pool's request depends on\n // another's answer and they can all be in flight at once. A failed pool\n // contributes no rows and does not stop the others: one enterprise tenant\n // being unreachable must not blank the whole staff list.\n //\n // The id list is read ONCE and reused. It is cached on a 60s TTL, so asking\n // twice can hand back two different lists, and pairing a page from one list\n // with an id from the other would label a user with the wrong tenant.\n const tenantIds = await listAuthTenantIds()\n const tenantPages = await mapPoolsConcurrently(tenantIds, async (tenantId) => {\n try {\n return await authForPool(tenantId).listUsers(TENANT_USER_CAP)\n } catch (error) {\n console.error(`listing users for tenant ${tenantId} failed`, error)\n return null\n }\n })\n tenantPages.forEach((tenantPage, index) => {\n if (!tenantPage) return\n const tenantId = tenantIds[index]\n users.push(...tenantPage.users.map((record) => ({ record, tenantId })))\n if (tenantPage.pageToken) tenantTruncated.push(tenantId)\n })\n return {\n // Marked only here, where every pool's rows are in hand (AGL-1962). The\n // earlier return above carries project rows alone, and a uid cannot\n // collide with itself inside one pool, so there is nothing to find there.\n users: markCrossPoolUidCollisions(users),\n nextPageToken: null,\n tenantsIncluded: true,\n tenantTruncated,\n }\n}\n\n/**\n * Every account across every pool, up to a cap — the only way to FILTER them.\n *\n * Firebase Auth's `listUsers` takes a page size and a cursor and nothing else:\n * no predicate, no ordering, no search. So a staff filter on anything but an\n * exact email, uid or phone number (which have their own O(1) lookups) can\n * only be answered by reading the accounts and matching them.\n *\n * That is an expensive read, so it happens on an ASK and never on a mount —\n * the route calls this only when a request carries a filter, and pages the\n * cheap way otherwise.\n *\n * `truncated` is true when the cap was reached, and it must be shown rather\n * than swallowed: a staff list that searched part of the directory and said\n * \"no matches\" is worse than one that admits where it stopped.\n */\nexport async function scanUsersAcrossPools(\n cap: number,\n): Promise<{\n users: PooledUserRecord[]\n truncated: boolean\n tenantTruncated: string[]\n}> {\n const users: PooledUserRecord[] = []\n let truncated = false\n let pageToken: string | undefined\n\n do {\n const remaining = cap - users.length\n if (remaining <= 0) {\n truncated = true\n break\n }\n // `listUsers` caps a page at 1000 whatever it is asked for.\n const page = await auth().listUsers(Math.min(remaining, 1000), pageToken)\n users.push(...page.users.map((record) => ({ record, tenantId: null })))\n pageToken = page.pageToken\n if (pageToken && users.length >= cap) truncated = true\n } while (pageToken && users.length < cap)\n\n const tenantTruncated: string[] = []\n const tenantIds = await listAuthTenantIds()\n /*\n * The remaining budget is fixed BEFORE any pool is asked.\n *\n * Sizing each pool's page from the running total is what forced these calls\n * into a series — pool `k`'s page size could not be known until pool `k-1`\n * had answered. Nothing needs that: a page sized to the whole remaining\n * budget is always at least as large as the shrinking one, so the first\n * `cap` rows in tenant order are the same users either way, and the pools\n * can be asked together.\n *\n * The trade is bandwidth, not quota: `listUsers` is billed per CALL, and the\n * call count is unchanged. Only when the cap actually binds does this carry\n * rows that are then trimmed.\n */\n const budget = cap - users.length\n if (budget <= 0) {\n truncated = true\n } else {\n const pageSize = Math.min(budget, TENANT_USER_CAP)\n const pages = await mapPoolsConcurrently(tenantIds, async (tenantId) => {\n try {\n return await authForPool(tenantId).listUsers(pageSize)\n } catch (error) {\n console.error(`listing users for tenant ${tenantId} failed`, error)\n return null\n }\n })\n pages.forEach((page, index) => {\n if (!page) return\n const tenantId = tenantIds[index]\n const room = cap - users.length\n const rows = page.users.map((record) => ({ record, tenantId }))\n // A pool whose rows will not fit is still a pool that was cut short, and\n // it has to say so. A scan that silently dropped one would answer \"no\n // such account\" for everyone in it — the exact failure this module\n // exists to prevent, wearing the shape of a complete result.\n if (rows.length > room) {\n truncated = true\n tenantTruncated.push(tenantId)\n } else if (page.pageToken) {\n tenantTruncated.push(tenantId)\n }\n if (room > 0) users.push(...rows.slice(0, room))\n })\n }\n\n return {\n users: markCrossPoolUidCollisions(users),\n truncated,\n tenantTruncated,\n }\n}\n\n/** Where a claim mutation landed, so a caller can audit the POOL as well. */\nexport interface StaffClaimWrite {\n uid: string\n /** The pool the claim was written to; null = the project pool. */\n tenantId: string | null\n email: string | null\n claims: Record<string, unknown>\n}\n\n/**\n * Write custom claims to the pool the uid ACTUALLY lives in (AGL-1993).\n *\n * The hazard this exists to remove: `setCustomUserClaims` is per-pool, and a\n * grant aimed at the wrong pool does not error — on the project pool it\n * either throws `user-not-found` (loud, fine) or, when a phantom shadow record\n * shares the uid, silently succeeds against the WRONG record. That is not\n * hypothetical: on 2026-08-18 a staff account's uid existed in both the\n * project pool and `aglyn-org-y5v14` at once. That phantom won every lookup\n * until AGL-2005 made {@link findUserByUidAcrossPools} rank an IDENTIFIED\n * record above an unidentified one rather than taking the first pool to\n * answer.\n *\n * So resolution and mutation are bound together here rather than left to each\n * caller to pair correctly, and the resolved pool is RETURNED so the audit row\n * can record which pool was written — \"granted staff to uid X\" is not a\n * complete record when a uid can name two accounts.\n *\n * Never assumes a pool. Staff may live in the project pool, in Aglyn's own\n * tenant, or in a CUSTOMER org's tenant, and hard-coding any of them would\n * silently fail the moment a third exists.\n */\nexport async function setClaimsInOwningPool(\n uid: string,\n claims: Record<string, unknown>,\n): Promise<StaffClaimWrite | null> {\n const found = await findUserByUidAcrossPools(uid)\n if (!found) return null\n await authForPool(found.tenantId).setCustomUserClaims(uid, claims)\n return {\n uid,\n tenantId: found.tenantId,\n email: found.record.email ?? null,\n claims,\n }\n}\n\n/**\n * Every uid carrying the `staff` claim, across all pools. Staff is a custom\n * claim, which is not queryable, so this scans — the caller caches it.\n */\nexport async function listStaffUidsAcrossPools(): Promise<string[]> {\n /*\n * Paging WITHIN a pool stays serial — each page's cursor comes out of the\n * one before it, so there is nothing to overlap. Across pools there is: the\n * pools are independent, and a staff notification waiting on them one after\n * another gets slower with every enterprise customer signed.\n *\n * Each scan collects into its own array rather than a shared one, so the\n * result is ordered by POOL rather than by which pool happened to answer\n * first. A caller diffing this list against a stored copy would otherwise\n * see phantom changes on every run.\n */\n const scan = async (pool: BaseAuth): Promise<string[]> => {\n const found: string[] = []\n let pageToken: string | undefined\n do {\n const page = await pool.listUsers(1000, pageToken)\n for (const record of page.users) {\n if (record.customClaims?.['staff']) found.push(record.uid)\n }\n pageToken = page.pageToken\n } while (pageToken)\n return found\n }\n const tenantIds = await listAuthTenantIds()\n const [projectUids, tenantUidLists] = await Promise.all([\n scan(auth()),\n mapPoolsConcurrently(tenantIds, async (tenantId) => {\n try {\n return await scan(authForPool(tenantId))\n } catch (error) {\n console.error(`staff scan for tenant ${tenantId} failed`, error)\n return [] as string[]\n }\n }),\n ])\n return [...projectUids, ...tenantUidLists.flat()]\n}\n\n/**\n * How many accounts the staff Users list holds (AGL-3660).\n *\n * Firebase Auth has no count aggregate, and the Users list is Auth across\n * every pool — not the Firestore `users/{uid}` profile docs, which are made on\n * demand and can be missing for a real account or outlive an erased one. So\n * the count walks the same directory the list does and collapses a uid that\n * shows in more than one pool to one person, exactly as the list's rows do.\n * `truncated` means the cap was reached and the figure is a floor.\n */\nexport async function countUsersAcrossPools(\n cap = 20000,\n): Promise<{ count: number; truncated: boolean }> {\n const scan = await scanUsersAcrossPools(cap)\n return {\n count: collapseCrossPoolUidRows(scan.users).length,\n truncated: scan.truncated || scan.tenantTruncated.length > 0,\n }\n}\n"],"names":["firebaseAdmin","auth","app","isIdentifiedUserRecord","record","Boolean","email","providerData","length","identityStrength","score","phoneNumber","displayName","Object","keys","customClaims","collapseCrossPoolUidRows","users","marked","markCrossPoolUidCollisions","winnerByUid","Map","user","incumbent","get","uid","set","emitted","Set","collapsed","has","add","push","poolsByUid","pools","tenantId","map","others","filter","_","index","indexOf","uidAlsoInPools","tenantCache","TENANT_CACHE_MS","listAuthTenantIds","now","Date","at","ids","pageToken","page","tenantManager","listTenants","tenants","tenant","error","console","resetAuthTenantCache","authForPool","authForTenant","findUserByEmailAcrossPools","address","trim","toLowerCase","getUserByEmail","findUserByUidAcrossPools","matches","getUser","TENANT_USER_CAP","POOL_FETCH_CONCURRENCY","mapPoolsConcurrently","tenantIds","work","results","Array","next","workers","from","Math","min","Promise","all","listUsersAcrossPools","pageSize","listUsers","nextPageToken","tenantsIncluded","tenantTruncated","tenantPages","forEach","tenantPage","scanUsersAcrossPools","cap","truncated","remaining","budget","pages","room","rows","slice","setClaimsInOwningPool","claims","found","setCustomUserClaims","listStaffUidsAcrossPools","scan","pool","projectUids","tenantUidLists","flat","countUsersAcrossPools","count"],"mappings":"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"}
|
|
@@ -238,6 +238,33 @@ const firestoreApp = ()=>firebaseAdmin.app().firestore();
|
|
|
238
238
|
exported: true,
|
|
239
239
|
note: 'Commerce events still owed to a connected marketing platform (AGL-3639): checkout started, order paid, fulfilled, refunded or cancelled, with the shopper’s email, the order, its items and value, and the tracking number. A SHOPPER’s data held for the merchant as controller, so disclosed to the ORG only, for the reason `supplierDeliveries` gives; a row is deleted once delivered.'
|
|
240
240
|
},
|
|
241
|
+
{
|
|
242
|
+
collection: 'adConversionConnections',
|
|
243
|
+
keyedBy: 'field',
|
|
244
|
+
subjects: [
|
|
245
|
+
'org'
|
|
246
|
+
],
|
|
247
|
+
exported: true,
|
|
248
|
+
note: 'EXISTENCE ONLY for the credential — each site’s Conversions API connection to the merchant’s own Meta, TikTok or Pinterest ad account (AGL-3694): provider, status, the Pinterest ad account id, the test event code, and what was last sent or refused. The sealed access token and the id of the key that sealed it carry `token` in their names and are redacted (see redactSecrets); the document id is the site id and the provider.'
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
collection: 'adConversionConsents',
|
|
252
|
+
keyedBy: 'field',
|
|
253
|
+
subjects: [
|
|
254
|
+
'org'
|
|
255
|
+
],
|
|
256
|
+
exported: true,
|
|
257
|
+
note: 'The advertising consent a shopper’s checkout carried, recorded only where it granted advertising on a site with a connection (AGL-3694): the consent status and when it was decided, the country, the page, the ad vendors’ browser ids, the IP address and the user agent. A SHOPPER’s data held for the merchant as controller, so disclosed to the ORG only; deleted when the order is reported or after 14 days.'
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
collection: 'adConversionEvents',
|
|
261
|
+
keyedBy: 'field',
|
|
262
|
+
subjects: [
|
|
263
|
+
'org'
|
|
264
|
+
],
|
|
265
|
+
exported: true,
|
|
266
|
+
note: 'Purchases and leads owed to a connected ad account (AGL-3694): the event id, order id, value and items, the visitor’s details as SHA-256 hashes, and the IP address, user agent and vendor browser ids. A SHOPPER’s data held for the merchant as controller, so disclosed to the ORG only; the personal fields are dropped once the event is sent, and every row expires after 14 days.'
|
|
267
|
+
},
|
|
241
268
|
{
|
|
242
269
|
collection: 'fulfillmentNetworkConnections',
|
|
243
270
|
keyedBy: 'field',
|
|
@@ -245,7 +272,7 @@ const firestoreApp = ()=>firebaseAdmin.app().firestore();
|
|
|
245
272
|
'org'
|
|
246
273
|
],
|
|
247
274
|
exported: true,
|
|
248
|
-
note: 'EXISTENCE ONLY for the grant — each site’s connection to the merchant’s own ShipBob or Amazon Multi-Channel Fulfillment account (AGL-3634): network, status, account name, ShipBob channel, Amazon marketplaces, routing and stock settings, the last stock count by SKU and totals. The sealed access and refresh tokens, the webhook token’s hash and the id of the key that sealed them carry `token` in their names and are redacted (see redactSecrets)
|
|
275
|
+
note: 'EXISTENCE ONLY for the grant — each site’s connection to the merchant’s own ShipBob, ShipMonk or Amazon Multi-Channel Fulfillment account (AGL-3634, AGL-3697): network, status, account name, ShipBob channel, ShipMonk store id, Amazon marketplaces, routing and stock settings, the last stock count by SKU and totals. The sealed access and refresh tokens (for ShipMonk, the merchant’s own API key, sealed as the access token), the webhook token’s hash and the id of the key that sealed them carry `token` in their names, and ShipMonk’s sealed webhook signing secret carries `secret` in its name; all are redacted (see redactSecrets). The document id is the site id and the network.'
|
|
249
276
|
},
|
|
250
277
|
{
|
|
251
278
|
collection: 'fulfillmentNetworkOrders',
|
|
@@ -256,6 +283,24 @@ const firestoreApp = ()=>firebaseAdmin.app().firestore();
|
|
|
256
283
|
exported: true,
|
|
257
284
|
note: 'Each paid order’s hand-off to a fulfillment network (AGL-3634): the order number, the lines sent with SKU and quantity, the network’s reference, the parcels read back with carrier, tracking number and status, and why anything was not sent. The merchant’s own fulfillment record of a SHOPPER’s order, so disclosed to the ORG only.'
|
|
258
285
|
},
|
|
286
|
+
{
|
|
287
|
+
collection: 'courierConnections',
|
|
288
|
+
keyedBy: 'field',
|
|
289
|
+
subjects: [
|
|
290
|
+
'org'
|
|
291
|
+
],
|
|
292
|
+
exported: true,
|
|
293
|
+
note: 'EXISTENCE ONLY for the keys — each site’s connection to the merchant’s own DoorDash Drive developer account (AGL-3695): provider, the developer and key ids of its live and test keys, each key’s last test result, the store’s courier phone and note, and the member who connected it. The sealed signing secrets carry `secret` and the webhook token’s hash carries `token` in their names and are redacted (see redactSecrets); the document id is the site id and the provider.'
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
collection: 'courierDeliveries',
|
|
297
|
+
keyedBy: 'field',
|
|
298
|
+
subjects: [
|
|
299
|
+
'org'
|
|
300
|
+
],
|
|
301
|
+
exported: true,
|
|
302
|
+
note: 'Each local-delivery order’s courier (AGL-3695): the quote offered, the run booked and the runs before it — the courier’s reference, status, fee, arrival estimates, tracking link and any cancellation reason, and the member who booked it. No address or phone is stored here (they stay on the order); the merchant’s own record of a SHOPPER’s delivery, so disclosed to the ORG only.'
|
|
303
|
+
},
|
|
259
304
|
{
|
|
260
305
|
collection: 'marketplaceConnections',
|
|
261
306
|
keyedBy: 'field',
|
|
@@ -1050,8 +1095,13 @@ function sourcesFor(subject) {
|
|
|
1050
1095
|
'taxEngineTransactions',
|
|
1051
1096
|
'marketingPlatformConnections',
|
|
1052
1097
|
'marketingPlatformEvents',
|
|
1098
|
+
'adConversionConnections',
|
|
1099
|
+
'adConversionConsents',
|
|
1100
|
+
'adConversionEvents',
|
|
1053
1101
|
'fulfillmentNetworkConnections',
|
|
1054
1102
|
'fulfillmentNetworkOrders',
|
|
1103
|
+
'courierConnections',
|
|
1104
|
+
'courierDeliveries',
|
|
1055
1105
|
'marketplaceConnections',
|
|
1056
1106
|
'marketplaceOrders',
|
|
1057
1107
|
'inventorySyncConnections',
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/personal-data-export.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Personal-data export — access and portability, in a machine-readable form\n * (AGL-1974).\n *\n * Privacy Policy §7 grants portability (GDPR Art. 20) and nothing in the\n * product exported a person's or a workspace's personal data. Six surfaces\n * told customers to \"export first\" before deleting — the org settings Delete\n * tab, the Close account card, `erase-org-cli.mjs`, the staff org actions,\n * live DPA §11 and Terms §13.3 — all pointing at something that did not\n * exist. An access request was assembled by a staff member reading the console\n * by hand.\n *\n * ## THE PROPERTY THIS FILE EXISTS FOR\n *\n * **An export whose coverage differs from erasure's is a bug in one of them.**\n * Both answer the same question — *what do we hold about this subject* — and\n * the honest failure modes are symmetric: an export that misses a collection\n * under-discloses on an access request, and an erasure that misses the same\n * one leaves data behind after we said it was gone. Historically the second\n * kept happening (AGL-1444, AGL-1448, AGL-1970, AGL-1971 each found a\n * collection outside the cascade), which is exactly the evidence that a list\n * kept in one place and mirrored by hand in another will drift.\n *\n * The ideal is one enumeration both derive from. That is not available here:\n * `erase.ts` is under concurrent change and its sweep list is expressed as\n * code, not data. So the coverage is enumerated ONCE below, as data, and\n * `personal-data-export-coverage.spec.ts` DISCOVERS every collection\n * `erase.ts` touches out of its source and fails when the two disagree. A new\n * sweep added to the erasure turns this file red until somebody decides\n * whether the export discloses it — which is the decision that was being\n * skipped.\n *\n * That guard is worth more than the feature.\n *\n * ## What must NOT come out wholesale\n *\n * Three categories, and each is a way an access request becomes a breach:\n *\n * 1. **Another person's data.** A workspace is shared. A member asking what\n * we hold about THEM gets their own roster row, not the roster; their own\n * support messages, not the thread's other authors. Handled by exporting\n * the person and the workspace as different subjects with different\n * scopes, never by filtering a single blob after the fact.\n * 2. **Secrets.** An `apiKeys` row confirms that a credential exists — its\n * label, scopes, creator and dates, which are all facts about the subject\n * — and never re-discloses the credential. Note the document ID *is* the\n * SHA-256 of the token, so the id itself is withheld too; a hash handed\n * back is a verifier handed back.\n * 3. **Staff-only fields.** Internal notes, override reasons and moderation\n * state are about our handling, not about the person, and several name a\n * staff member.\n *\n * ## What it is NOT\n *\n * Not `GET /api/hosts/export` — that is a Pro+ site-design backup whose own\n * header says it never includes bookings, leads, submissions or secrets, and\n * offering it as a portability answer would be misdescribing it. Not the\n * `firestore-export` cron, which is an unscoped DR snapshot. Not the audit CSV.\n */\n\nimport { firebaseAdmin } from './firebase-admin'\nimport { CONSOLE_DOMAINS_COLLECTION } from './console-domains'\n\nconst firestoreApp = () => firebaseAdmin.app().firestore()\n\n/** Which subject an export source belongs to. */\nexport type ExportSubject = 'user' | 'org'\n\n/** How a source is located — the axis a path cascade is blind to. */\nexport type ExportKeying =\n /** Top-level collection carrying the subject id in a FIELD. */\n | 'field'\n /** Top-level collection whose DOCUMENT ID is the subject id. */\n | 'doc-id'\n /** Under the subject's own document path. */\n | 'path'\n /** Reached through another collection's rows (a collection group). */\n | 'collection-group'\n /** Outside Firestore entirely. */\n | 'external'\n\nexport interface ExportSourceSpec {\n /** Firestore collection name, or an `external:` pseudo-name. */\n collection: string\n keyedBy: ExportKeying\n subjects: readonly ExportSubject[]\n /**\n * `true` when the export discloses it. `false` requires `reason` — and the\n * reason is the whole value of the field: it is a decision recorded at the\n * moment it was made, not an omission discovered later.\n */\n exported: boolean\n /** Why it is withheld, or how it is disclosed. Always present. */\n note: string\n}\n\n/**\n * Every collection `erase.ts` touches, and what the export does with it.\n *\n * Kept in the SAME order as the erasure's steps so a reader comparing the two\n * files reads them in step. `personal-data-export-coverage.spec.ts` proves the\n * set matches; this list carries the decision for each one.\n */\nexport const PERSONAL_DATA_SOURCES: readonly ExportSourceSpec[] = [\n // ── The person ────────────────────────────────────────────────────────\n {\n collection: 'users',\n keyedBy: 'doc-id',\n subjects: ['user'],\n exported: true,\n note: 'Profile: name, phone, postal address, legalAcceptances, and every subcollection (orgs, hostMemberships, notifications, passkeys).',\n },\n {\n collection: 'profiles',\n keyedBy: 'doc-id',\n subjects: ['user'],\n exported: true,\n note: 'The world-readable handle, display name and Stripe Connect account. Not reachable from the user tree (AGL-1970) — the same blind spot on both sides.',\n },\n // ── The workspace ─────────────────────────────────────────────────────\n {\n collection: 'orgs',\n keyedBy: 'doc-id',\n subjects: ['org'],\n exported: true,\n note: 'The org document and every subcollection: members, invites, roles, usage, apiUsage, analytics, datasets/records, contacts, lists, media, installs, activity, assistExchanges, retention.',\n },\n {\n collection: 'hosts',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Each site tree the org owns — pages, layouts, orders, form submissions. Secrets within are redacted, not omitted, so the export still says a webhook exists.',\n },\n {\n collection: 'hostIndex',\n keyedBy: 'doc-id',\n subjects: ['org'],\n exported: true,\n note: 'Subdomain/CNAME routing for each of the org’s sites. Configuration the customer set, so it is theirs to take.',\n },\n {\n collection: 'supplierDeliveries',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'The dropship outbox for each of the org’s sites, keyed by hostId (AGL-1448). Disclosed to the ORG and to no one else, and the reason is the unusual one: the personal data in the row is a SHOPPER’s — name, email and shipping address in the body we sign and POST to the supplier. That is the merchant’s own order record, which they already read in their console and for which they are the controller and we the processor, so an org access request is entitled to it. It is NOT a `user`-subject source: a console member asking what we hold about THEM is not the shopper, and returning these rows would answer their request with a third party’s address. Withholding it was not an option worth taking — `eraseHost` destroys these rows, so an export that omitted them would delete what it never disclosed.',\n },\n // ── Top-level, field-keyed: what a path cascade cannot see ────────────\n {\n collection: 'apiKeys',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'EXISTENCE ONLY — label, scopes, creating uid, dates. Never the credential, and never the document id, which is the SHA-256 of the token.',\n },\n {\n collection: 'outreachMailboxCredentials',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'EXISTENCE ONLY — which connected mailbox a credential authorizes, its provider, granted scopes and dates (AGL-2974), and since AGL-2978 the connected account’s address, its Google account id and the member who connected it: the organization’s own record of whose mailbox it holds. Every token is redacted by name and by shape (see redactSecrets) and never disclosed; the sealed refresh token and the id of the key that sealed it both carry `token` in their names. The document id is the mailbox id, not derived from any secret, so it is disclosed.',\n },\n {\n collection: 'outreachLinks',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Where each short tracking link in a sent Outreach sequence email points (AGL-3297): the enrollment, the step, the link’s index and the destination the step wrote. The organization’s own record of what it sent; the id is random and derived from no secret.',\n },\n {\n collection: 'shippingTrackers',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'One row per tracking number a label was bought for (AGL-3612): the site, the order record, the carrier, the tracking number and its latest status. The organization’s own record of what it shipped; the order it points at is already disclosed under its site. Org-only, for the reason `supplierDeliveries` gives: a tracking number locates a shopper’s parcel.',\n },\n {\n collection: 'shippingQuoteCache',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Carrier quotes for a site’s checkout, kept ten minutes (AGL-3612): the site, each service’s price and transit estimate, and when the row expires. No address is stored; the document id is a SHA-256 of the quoted request, an identifier rather than a verifier of any credential. Disclosed while it exists so the export does not omit a collection the erasure destroys.',\n },\n {\n collection: 'taxEngineConnections',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'EXISTENCE ONLY for the credential — each site’s connection to the merchant’s own Avalara AvaTax or TaxJar account (AGL-3631): provider, environment, account id and company code, ship-from address, default tax code, last test result and the member who connected it. The sealed API token and the id of the key that sealed it both carry `token` in their names and are redacted (see redactSecrets); the document id is the site id.',\n },\n {\n collection: 'taxEngineProductCodes',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'The tax code the merchant assigned each product at their tax service (AGL-3631). Catalog configuration the customer set, so it is theirs to take.',\n },\n {\n collection: 'taxEngineExemptions',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Customers the merchant recorded as tax-exempt (AGL-3631): email, name, exemption type, certificate number and the regions it covers. A SHOPPER’s data held for the merchant as controller, so disclosed to the ORG only, for the reason `supplierDeliveries` gives. The document id carries a truncated SHA-256 of the email — a lookup key, not a credential.',\n },\n {\n collection: 'taxEngineTransactions',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Each paid order as recorded with the merchant’s tax service, and the refunds reversed against it (AGL-3631): the sale sent (lines, ship-to address, the customer code, which is the shopper’s email), its status and last error. The merchant’s own tax record and a shopper’s address, so disclosed to the ORG only.',\n },\n {\n collection: 'marketingPlatformConnections',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'EXISTENCE ONLY for the credential — each site’s connection to the merchant’s own Mailchimp, Klaviyo, Omnisend or Attentive account (AGL-3639): provider, status, account name, the chosen list and tag, the sync switches, cursors and run timing. The sealed access and refresh tokens and the id of the key that sealed them carry `token` in their names and are redacted (see redactSecrets); the document id is the site id and the provider.',\n },\n {\n collection: 'marketingPlatformEvents',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Commerce events still owed to a connected marketing platform (AGL-3639): checkout started, order paid, fulfilled, refunded or cancelled, with the shopper’s email, the order, its items and value, and the tracking number. A SHOPPER’s data held for the merchant as controller, so disclosed to the ORG only, for the reason `supplierDeliveries` gives; a row is deleted once delivered.',\n },\n {\n collection: 'fulfillmentNetworkConnections',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'EXISTENCE ONLY for the grant — each site’s connection to the merchant’s own ShipBob or Amazon Multi-Channel Fulfillment account (AGL-3634): network, status, account name, ShipBob channel, Amazon marketplaces, routing and stock settings, the last stock count by SKU and totals. The sealed access and refresh tokens, the webhook token’s hash and the id of the key that sealed them carry `token` in their names and are redacted (see redactSecrets); the document id is the site id and the network.',\n },\n {\n collection: 'fulfillmentNetworkOrders',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Each paid order’s hand-off to a fulfillment network (AGL-3634): the order number, the lines sent with SKU and quantity, the network’s reference, the parcels read back with carrier, tracking number and status, and why anything was not sent. The merchant’s own fulfillment record of a SHOPPER’s order, so disclosed to the ORG only.',\n },\n {\n collection: 'marketplaceConnections',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'EXISTENCE ONLY for the grant — each site’s connection to the merchant’s own Amazon, eBay, Etsy, TikTok Shop, Walmart or Faire seller account (AGL-3638): marketplace, status, account name, the marketplace sites the account sells on, the listing and order settings, the order cursor and the last sync’s counts. The sealed access and refresh tokens and the id of the key that sealed them carry `token` in their names and are redacted (see redactSecrets); the account’s marketplace ids are disclosed as stored. The document id is the site id and the marketplace. Its `log` and `listingState` subcollections (what each listing was last sent) are not read by this export.',\n },\n {\n collection: 'marketplaceOrders',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Each marketplace order the store imported (AGL-3638): the marketplace and its order id, the store order it became, which order line each marketplace line became, the marketplace’s fees, and each shipment confirmed back with carrier and tracking number. The merchant’s own record of a marketplace BUYER’s order, so disclosed to the ORG only; the buyer’s name and address are on the store’s order, not here.',\n },\n {\n collection: 'inventorySyncConnections',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'EXISTENCE ONLY for the keys — each site’s connection to the merchant’s own Cin7 Core, inFlow or Brightpearl account (AGL-3642): system, status, account name, the stock, product and order settings (location, the customer orders are recorded under, the tax rule), the last count by SKU, the last syncs’ counts, cursors and totals. The sealed API keys or grant and the id of the key that sealed them carry `credential` in their names and are redacted (see redactSecrets); Brightpearl’s account code and datacenter are disclosed as stored. The document id is the site id. Its `log` subcollection is not read by this export.',\n },\n {\n collection: 'inventorySyncOrders',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Each paid order’s hand-off to the connected inventory system (AGL-3642): the order number and our reference, the lines sent with SKU, quantity and price, the buyer’s name, email address and shipping address as sent, the totals, the system’s id and number for it, attempts and why it was not sent. The merchant’s own record of a SHOPPER’s order, so disclosed to the ORG only.',\n },\n {\n collection: 'inventorySyncProducts',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Each product linked between the store and the connected inventory system (AGL-3642): the system’s product id, the SKU, the version last synced, the store’s product and variant ids, and why it was not synced. Catalog data, no person’s.',\n },\n {\n collection: 'podConnections',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'EXISTENCE ONLY for the tokens — each site’s connection to the merchant’s own Printful store or Printify shop (AGL-3641): service, store id and name, currency, whether orders are sent automatically or held for review, whether prices sync, how shipments are heard about, and the last error. The sealed API token and webhook token and the id of the key that sealed them carry `token` in their names and are redacted (see redactSecrets).',\n },\n {\n collection: 'podProductLinks',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Each product imported from a print-on-demand service (AGL-3641): the service’s product and variant ids, the store product and variants it became, names, SKUs, what the service charges for each variant and the store’s price. Catalog data, no person’s.',\n },\n {\n collection: 'podOrders',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'The part of each paid order a print-on-demand service makes and ships (AGL-3641): the store order and its number, the service’s order id, the lines sent with quantity and cost, its status, costs, attempts and errors, and each parcel’s carrier and tracking number. No buyer name, address or email is kept here; they are on the store’s order. The merchant’s own record of a SHOPPER’s order, so disclosed to the ORG only.',\n },\n {\n collection: 'deliveryAppStores',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Each site’s link to the merchant’s own DoorDash, Uber Eats or Grubhub store (AGL-3644): the service, the store id it shows the merchant, the settings (accept automatically, prep minutes), the items matched to products and those waiting, and the menu’s last send. No credential is stored: every call is signed with the deployment’s partner credentials.',\n },\n {\n collection: 'deliveryAppOrders',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Each order a delivery service sent a site’s register (AGL-3644): the service and its order id and short code, the items with their options and instructions, the totals and refunds, where it stood at the counter, and the store order it became. The buyer appears by first name and last initial only. The merchant’s own record of a delivery BUYER’s order, so disclosed to the ORG only.',\n },\n {\n collection: 'zapierHooks',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Each Zapier REST hook a site’s API key subscribed (AGL-3643): the site, the events, the API key’s public id and name, delivery counts and dates. EXISTENCE ONLY for the URL — `targetUrl` is the capability Zapier minted for the Zap, a bearer URL whose secret is in its path, so it is redacted by name (see redactSecrets).',\n },\n {\n collection: 'zapierHookDeliveries',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Markers that one event reached one Zapier hook, so a retry does not post it twice (AGL-3643): the hook, the status and the time. The document id is a SHA-256 of the event and hook, an identifier rather than a verifier of any credential; no customer data, and a TTL policy removes each row.',\n },\n {\n collection: 'ssoDomains',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'The public SSO routing document: domain, GCIP tenant and provider ids, IdP display name. `token` is redacted.',\n },\n {\n collection: CONSOLE_DOMAINS_COLLECTION,\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Custom console domain claims the org holds.',\n },\n {\n collection: 'apiIdempotency',\n keyedBy: 'field',\n subjects: ['org'],\n exported: false,\n note: 'Replay keys. The document id is a SHA-256 of the org id and the caller’s Idempotency-Key, and the row names a record id and nothing about a person. Disclosed as a COUNT so the export does not silently omit a collection the erasure destroys.',\n },\n {\n collection: 'stripeCustomers',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'The local reverse index: which Stripe customer id belongs to this workspace. An identifier the customer already holds on their own invoices.',\n },\n {\n collection: 'orgSlugs',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Every workspace name the org has ever held, rename tombstones included — naming history is a fact about them.',\n },\n {\n collection: 'publisherHandles',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Marketplace handle reservations and rename tombstones (AGL-1970).',\n },\n {\n collection: 'publisherProfiles',\n keyedBy: 'doc-id',\n subjects: ['org'],\n exported: true,\n note: 'The public publisher identity — handle, display name, bio, website, avatar, publisher agreement. `stripeAccountId` is the org’s own payout destination and is disclosed; nothing here is a credential.',\n },\n {\n collection: 'marketplaceListings',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Listings the org published. These deliberately OUTLIVE an erasure (AGL-1448 Tier 3) — which makes disclosing them on an access request more important, not less: they are the part that does not go away.',\n },\n {\n collection: 'supportTickets',\n keyedBy: 'field',\n subjects: ['org', 'user'],\n exported: true,\n note: 'For an ORG: every thread and its messages. For a PERSON: only the messages they themselves authored — a thread has other authors, and handing a member the whole conversation would disclose colleagues’ words to them.',\n },\n // ── Retained past erasure on statutory grounds ────────────────────────\n {\n collection: 'platformRevenue',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Per-transaction tax filing records. GDPR Art. 17(3)(b) exempts them from ERASURE (AGL-1811); it does not exempt them from ACCESS. If a transaction of theirs is in there it is disclosable — and the export is the only place a customer can see what survived their erasure.',\n },\n {\n collection: 'storefrontTaxCollected',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Sales tax charged to shoppers on the org’s storefront (AGL-1904). Same statutory retention, same access obligation.',\n },\n // ── Records of our own handling ───────────────────────────────────────\n {\n collection: 'adminAudit',\n keyedBy: 'field',\n subjects: ['org', 'user'],\n exported: false,\n note: 'STAFF-ONLY. Rows name the staff actor, carry override reasons and internal notes, and are the record of our handling rather than data about the subject. `/admin/audit` → Export CSV serves a request that specifically asks for them, after a staff read.',\n },\n]\n\n/** Sources this export deliberately withholds, with the reason. */\nexport const EXPORT_WITHHELD = PERSONAL_DATA_SOURCES.filter((s) => !s.exported)\n\n/** Collection names the export claims to cover, for the coverage guard. */\nexport const EXPORT_COVERED_COLLECTIONS: readonly string[] =\n PERSONAL_DATA_SOURCES.map((source) => source.collection)\n\n/**\n * ## WHY THIS IS SHAPE-DRIVEN AND NOT A LIST OF NAMES\n *\n * It used to be a list of names, anchored on separators:\n *\n * /(^|[._-])(secret|token|password|apikey|…)($|[._-])/i\n *\n * That pattern requires a `.`, `_`, `-`, or a string boundary on both sides\n * of the word, so it reads `password_hash` and `webhooks.secret` and misses\n * `supplierToken` and `passwordScrypt` — the exact two fields the AGL-1881\n * review found leaving the platform in a customer's own export file. One is\n * a live bearer credential a supplier authenticates with; the other is a\n * shopper's scrypt password hash. **A denylist of names is always one naming\n * convention behind, and camelCase is the proof.**\n *\n * So the name half is now tokenized rather than separator-anchored, and it is\n * no longer the only gate. Three run, in order, and any of them redacts:\n *\n * * **(A) The name, tokenized.** `supplierToken` splits to\n * `['supplier','token']` the same way `supplier_token` and\n * `SUPPLIER-TOKEN` do, so the convention stops mattering. A name matches\n * on a single secret word or on an adjacent pair (`client secret`).\n * * **(B) The VALUE's shape, whatever the key is called.** This is the half\n * that does not need to know the schema: a PEM block, a JWT, a vendor\n * prefix (`sk_live_`, `whsec_`, `ghp_`, `AKIA`…), a `$2b$`/`$argon2` hash,\n * this repo's own `salt:hash` scrypt form, or an opaque high-entropy\n * string is a credential whether the field is called `supplierToken`,\n * `t`, or `notes`. **Gate B is the \"redact unless known safe\" default**:\n * a credential-shaped string is withheld unless its key names it as a\n * content identifier (see {@link SAFE_VALUE_WORDS}).\n * * **(C) Credentials carried inside a URL.** `?token=`, `?sig=`,\n * `?key=`, and `https://user:pass@host` are stripped from the URL rather\n * than the URL being blanked, because a media object's `url` field really\n * does carry a live Firebase download token in a query parameter, and the\n * location of the customer's own file is exactly what an access request\n * asks for. Precision beats blanking here.\n *\n * ### Why the KEY half is still deny-based, when the value half is not\n *\n * A true allowlist of field names is not available on this data. The export\n * walks documents whose field names our customers author — canvas nodes,\n * dataset records, custom metadata, form submissions — so an allowlist of\n * names would blank most of the file and turn an access request into a\n * non-answer, which is the under-disclosure this feature exists to stop. The\n * allowlist therefore lives where the vocabulary IS ours: value shapes. An\n * unrecognized VALUE shape is passed through (it is prose, a number, a date,\n * a customer's own content); an unrecognized value shape that looks like a\n * credential is withheld. That is the inversion the review asked for, put\n * where it can hold.\n *\n * ### The one deliberate carve-out\n *\n * `token`/`tokens` is also a metering word — `tokensUsed: 14203` on an\n * assist exchange is a usage count, and blanking it would blank a real\n * disclosure. A credential is a STRING; a count is a number. So a numeric or\n * boolean value under a name that matched only on a metering word passes\n * through. Nothing else in the vocabulary gets that treatment.\n *\n * A false positive costs a redacted field in an export. A false negative\n * costs a credential. Everything below is tuned in that direction.\n */\n\n/**\n * Split a field name into lowercase word tokens, convention-independently.\n *\n * `supplierToken`, `supplier_token`, `SUPPLIER-TOKEN` and `Supplier.Token`\n * all become `['supplier','token']`. Acronym runs split at the last capital\n * (`SSOTokenId` → `['sso','token','id']`) and digits break a word\n * (`apiKey2` → `['api','key','2']`), so a numbered field cannot hide.\n */\nexport function fieldNameTokens(key: unknown): string[] {\n return String(key ?? '')\n .replace(/[^A-Za-z0-9]+/g, ' ')\n .replace(/([a-z0-9])([A-Z])/g, '$1 $2')\n .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')\n .replace(/([A-Za-z])([0-9])/g, '$1 $2')\n .toLowerCase()\n .split(' ')\n .filter(Boolean)\n}\n\n/**\n * Words that make a field a credential on their own.\n *\n * Deliberately short. Every entry here is a word that has no innocent\n * meaning on a stored VALUE — `hmac`, `scrypt` and `bcrypt` name the\n * algorithm that produced a verifier, `passphrase` and `credential` name the\n * thing itself. `key` is NOT here and must never be: `publicKey` on a\n * passkey and `sortKey` on a dataset are not secrets, which is what\n * {@link SECRET_WORD_PAIRS} is for.\n */\nconst SECRET_WORDS = new Set([\n 'secret',\n 'secrets',\n 'password',\n 'passwords',\n 'passwd',\n 'pwd',\n 'passphrase',\n 'token',\n 'tokens',\n 'credential',\n 'credentials',\n 'apikey',\n 'privatekey',\n 'scrypt',\n 'bcrypt',\n 'argon',\n 'argon2',\n 'pbkdf2',\n 'hmac',\n 'otp',\n 'totp',\n 'authorization',\n])\n\n/**\n * Words that are a credential only next to another word.\n *\n * `key` is meaningless alone and decisive after `api`, `private`, `signing`,\n * `secret`, `session` or `encryption`; `token` after `access`/`refresh` is\n * already caught by {@link SECRET_WORDS} and listed here anyway so the pair\n * table reads as the complete statement of the rule.\n */\nconst SECRET_WORD_PAIRS = new Set([\n 'api key',\n 'private key',\n 'signing key',\n 'secret key',\n 'session key',\n 'encryption key',\n 'client secret',\n 'access token',\n 'refresh token',\n 'webhook secret',\n 'shared secret',\n // A Stripe payment link is a live bearer URL: whoever holds it can pay.\n // Named in the AGL-1443 inventory of what the old dump actually carried.\n 'payment link',\n // A Zapier REST hook's `targetUrl` (AGL-3643) is the same kind of bearer\n // URL: whoever holds it can post into the Zap, and its secret is in the\n // path, which the URL scrub does not read.\n 'target url',\n 'auth code',\n 'recovery code',\n 'backup code',\n])\n\n/**\n * The metering carve-out — see the header. A name that matched ONLY on one\n * of these, over a number or a boolean, is a count and is disclosed.\n */\nconst METERING_WORDS = new Set(['token', 'tokens'])\n\n/**\n * Key words that mark a high-entropy value as a content identifier rather\n * than a credential, defusing gate (B) only.\n *\n * A SHA-256 has exactly the shape of a 64-character secret and is not one.\n * Gate (A) is never defused by this list: `apiKeyId` is redacted, because\n * the name says credential and an id of a credential is not worth the\n * argument.\n */\nconst SAFE_VALUE_WORDS = new Set([\n // A passkey's `publicKey` is public by definition and has exactly the shape\n // of a secret. Safe to list because gate (A) is never defused here — a\n // `publicToken` is still redacted, on the strength of `token`.\n 'public',\n 'id',\n 'ids',\n 'uid',\n 'uuid',\n 'guid',\n 'hash',\n 'hashes',\n 'sha',\n 'sha1',\n 'sha256',\n 'sha512',\n 'md5',\n 'crc',\n 'crc32c',\n 'etag',\n 'checksum',\n 'digest',\n 'fingerprint',\n 'generation',\n 'version',\n 'revision',\n 'slug',\n 'path',\n 'url',\n 'uri',\n 'href',\n 'src',\n 'color',\n 'colour',\n 'hex',\n])\n\n/** Vendor credential prefixes — unambiguous, and worth naming explicitly. */\nconst CREDENTIAL_PREFIXES = [\n 'sk_live_',\n 'sk_test_',\n 'rk_live_',\n 'rk_test_',\n 'whsec_',\n 'ghp_',\n 'gho_',\n 'ghu_',\n 'ghs_',\n 'ghr_',\n 'github_pat_',\n 'xoxb-',\n 'xoxp-',\n 'xapp-',\n 'xoxa-',\n 'AKIA',\n 'ASIA',\n 'AIza',\n 'ya29.',\n 'SG.',\n 're_',\n 'shpat_',\n 'shpss_',\n 'npm_',\n 'glpat-',\n 'sq0csp-',\n 'sq0atp-',\n]\n\n/** A PEM block that carries a private key or a certificate request. */\nconst PEM_PATTERN = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/\n\n/** Modular crypt format: bcrypt, argon2, scrypt, PBKDF2, sha512-crypt. */\nconst CRYPT_HASH_PATTERN = /^\\$(2[abxy]?|argon2[a-z]*|scrypt|pbkdf2[^$]*|[56])\\$/\n\n/** This repo's own scrypt storage form: `{16+ hex salt}:{32+ hex hash}`. */\nconst SALTED_HASH_PATTERN = /^[0-9a-f]{16,}:[0-9a-f]{32,}$/i\n\n/** A JSON Web Token — three base64url segments. */\nconst JWT_PATTERN = /^[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}$/\n\n/** A UUID, which is an identifier far more often than it is a credential. */\nconst UUID_PATTERN =\n /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\n/** Charset of an opaque credential: base64, base64url or hex, nothing else. */\nconst OPAQUE_CHARSET = /^[A-Za-z0-9_+/=-]+$/\n\n/**\n * The window an opaque secret lives in.\n *\n * Below 32 characters the false-positive rate against ordinary identifiers is\n * not worth it and the value is too short to be a modern credential anyway.\n * Above 512 it is customer CONTENT — a base64 image, a compressed canvas\n * blob, an encoded document — and blanking those is the \"gutting the export\"\n * failure this file is explicitly trying not to commit.\n */\nconst OPAQUE_MIN_LENGTH = 32\nconst OPAQUE_MAX_LENGTH = 512\n\n/** Shannon entropy per character. Prose and slugs sit well below this. */\nconst OPAQUE_MIN_ENTROPY = 3.2\n\nfunction shannonEntropyPerChar(text: string): number {\n const counts = new Map<string, number>()\n for (const character of text) {\n counts.set(character, (counts.get(character) ?? 0) + 1)\n }\n let entropy = 0\n for (const count of counts.values()) {\n const p = count / text.length\n entropy -= p * Math.log2(p)\n }\n return entropy\n}\n\n/**\n * Does this STRING look like a credential, ignoring what it is called?\n *\n * Exported because the guard spec asserts each limb independently — a shape\n * detector tested only through the redactor is a detector whose gaps are\n * invisible.\n */\nexport function looksLikeCredential(value: string): boolean {\n const text = value.trim()\n if (!text) return false\n if (PEM_PATTERN.test(text)) return true\n if (CRYPT_HASH_PATTERN.test(text)) return true\n if (SALTED_HASH_PATTERN.test(text)) return true\n if (CREDENTIAL_PREFIXES.some((prefix) => text.startsWith(prefix))) return true\n if (JWT_PATTERN.test(text)) return true\n // Anything with whitespace is prose, and `data:` is an inline asset. Both\n // are customer content, and neither is a bearer credential.\n if (/\\s/.test(text)) return false\n if (/^data:/i.test(text)) return false\n if (UUID_PATTERN.test(text)) return false\n if (text.length < OPAQUE_MIN_LENGTH || text.length > OPAQUE_MAX_LENGTH) {\n return false\n }\n if (!OPAQUE_CHARSET.test(text)) return false\n return shannonEntropyPerChar(text) >= OPAQUE_MIN_ENTROPY\n}\n\n/** What a stripped URL parameter reads as, so the removal is visible. */\nexport const REDACTED_URL_PARAM = 'REDACTED'\n\n/** Query parameters that carry a bearer credential inside a URL. */\nconst URL_CREDENTIAL_PARAMS = new Set([\n 'token',\n 'access_token',\n 'accesstoken',\n 'id_token',\n 'refresh_token',\n 'auth',\n 'authorization',\n 'apikey',\n 'api_key',\n 'key',\n 'sig',\n 'signature',\n 'secret',\n 'password',\n 'downloadtoken',\n])\n\n/**\n * Strip the credential out of a URL, keeping the URL.\n *\n * A media object's `url` is `…/o/{path}?alt=media&token={uuid}` — a live,\n * unauthenticated download credential, in a field an access request has a\n * real interest in. Blanking the whole value would tell the customer nothing\n * about where their own file is; keeping it whole hands out the credential.\n * So the parameter is replaced and the rest survives. `user:pass@` userinfo\n * goes the same way.\n *\n * Returns the input unchanged when it is not a URL — every non-URL string\n * has already been decided by the shape gate.\n */\nexport function scrubUrlCredentials(value: string): string {\n if (!/^https?:\\/\\//i.test(value)) return value\n let url: URL\n try {\n url = new URL(value)\n } catch {\n return value\n }\n let changed = false\n if (url.username || url.password) {\n url.username = ''\n url.password = ''\n changed = true\n }\n for (const name of Array.from(url.searchParams.keys())) {\n if (!URL_CREDENTIAL_PARAMS.has(name.toLowerCase())) continue\n if (!url.searchParams.get(name)) continue\n url.searchParams.set(name, REDACTED_URL_PARAM)\n changed = true\n }\n return changed ? url.toString() : value\n}\n\n/**\n * Gate (A): does the NAME say credential?\n *\n * `value` is consulted only for the metering carve-out described in the\n * header — a `tokensUsed: 14203` is a count, not a credential.\n */\nexport function secretByName(key: unknown, value?: unknown): boolean {\n const tokens = fieldNameTokens(key)\n if (!tokens.length) return false\n const hits = tokens.filter((token) => SECRET_WORDS.has(token))\n for (let index = 0; index + 1 < tokens.length; index += 1) {\n if (SECRET_WORD_PAIRS.has(`${tokens[index]} ${tokens[index + 1]}`)) {\n return true\n }\n }\n if (!hits.length) return false\n // Matched only on a metering word, over a number or a boolean: a count.\n const metering = hits.every((token) => METERING_WORDS.has(token))\n if (metering && (typeof value === 'number' || typeof value === 'boolean')) {\n return false\n }\n return true\n}\n\n/** Gate (B)'s defuser: does the NAME say this high-entropy value is an id? */\nfunction nameMarksContentIdentifier(key: unknown): boolean {\n return fieldNameTokens(key).some((token) => SAFE_VALUE_WORDS.has(token))\n}\n\n/** What a redacted value is replaced with — presence, never content. */\nexport interface RedactedValue {\n redacted: true\n /** Whether a value was actually there, so absence is distinguishable. */\n present: boolean\n reason: 'secret'\n}\n\nconst redactedMarker = (present: boolean): RedactedValue => ({\n redacted: true,\n present,\n reason: 'secret',\n})\n\n/**\n * Deep-copy a document, replacing every credential-shaped value with a\n * presence marker.\n *\n * Presence rather than deletion, because \"we hold a webhook secret for you\"\n * is itself a true and disclosable fact, and a silently-absent key would make\n * the export look like it holds less than it does — the under-disclosure this\n * feature exists to stop, reintroduced by the safety measure.\n *\n * Firestore values that are not plain JSON (Timestamp, GeoPoint, DocumentRef,\n * Buffer) are stringified rather than walked: they carry no nested keys to\n * redact, and `JSON.stringify` on a Timestamp yields `{_seconds,_nanoseconds}`,\n * which is not a date to a human reading the file.\n *\n * Three gates, any of which redacts — the name (A), the value's shape (B),\n * and a credential embedded in a URL (C). See the header above\n * {@link fieldNameTokens} for why the value half is the one that carries the\n * \"withhold unless known safe\" default.\n */\nexport function redactSecrets(value: unknown, key = ''): unknown {\n // (A) The name, tokenized — convention-independent.\n if (key && secretByName(key, value)) {\n return redactedMarker(value !== undefined && value !== null && value !== '')\n }\n if (value === null || value === undefined) return value ?? null\n if (typeof value === 'string') {\n // (C) A URL that carries its credential in a parameter keeps the URL and\n // loses the credential — see {@link scrubUrlCredentials}.\n const scrubbed = scrubUrlCredentials(value)\n if (scrubbed !== value) return scrubbed\n // (B) The shape, whatever the field is called. Withheld unless the name\n // marks it as a content identifier.\n if (looksLikeCredential(value) && !nameMarksContentIdentifier(key)) {\n return redactedMarker(true)\n }\n return value\n }\n if (Array.isArray(value)) return value.map((entry) => redactSecrets(entry, key))\n if (typeof value === 'object') {\n const source = value as Record<string, unknown>\n // Firestore Timestamp — the only non-plain value common enough to be\n // worth rendering rather than dumping.\n if (typeof (source as any).toDate === 'function') {\n try {\n return (source as any).toDate().toISOString()\n } catch {\n return String(value)\n }\n }\n if (typeof (source as any).toBase64 === 'function') return '[binary]'\n const out: Record<string, unknown> = {}\n for (const [childKey, childValue] of Object.entries(source)) {\n out[childKey] = redactSecrets(childValue, childKey)\n }\n return out\n }\n return value\n}\n\nexport interface ExportedDocument {\n /** Document id — omitted where the id is itself a secret (see `apiKeys`). */\n id?: string\n path?: string\n data: unknown\n}\n\nexport interface PersonalDataExport {\n subject: { type: ExportSubject; id: string }\n generatedAt: string\n /**\n * The disclosure decision for every source, copied into the file the\n * customer receives. An export that silently omits a collection is\n * indistinguishable from one that has nothing to report; this makes the two\n * different, and it is what a supervisory authority would ask to see.\n */\n coverage: readonly ExportSourceSpec[]\n /** Collection name → the documents disclosed for it. */\n data: Record<string, ExportedDocument[]>\n /** Collection name → row count, for sources disclosed as a count only. */\n counts: Record<string, number>\n /** Places we hold data that is not in Firestore. */\n elsewhere: readonly string[]\n}\n\n/** Data we hold outside Firestore — named, because a file that omits them reads as complete. */\nconst ELSEWHERE = [\n 'Stripe — customer object, payment methods, invoices. Request from Stripe or ask us.',\n 'Resend — transactional email delivery logs.',\n 'Google Analytics — 14-month retention, not keyed to an account.',\n 'Cloud Storage — uploaded media and avatars, under users/{uid}/, orgs/{orgId}/ and hosts/{hostId}/. Listed by path in this file; the bytes are served by their own URLs.',\n]\n\nfunction sourcesFor(subject: ExportSubject): ExportSourceSpec[] {\n return PERSONAL_DATA_SOURCES.filter((source) => source.subjects.includes(subject))\n}\n\n/** Read a top-level collection bounded by a field equal to the subject id. */\nasync function readByField(\n db: any,\n collection: string,\n field: string,\n id: string,\n options: { includeIds?: boolean } = {},\n): Promise<ExportedDocument[]> {\n const rows = await db.collection(collection).where(field, '==', id).get()\n return rows.docs.map((doc: any) => ({\n // `apiKeys` document ids are the SHA-256 of the token: an id handed back\n // is a verifier handed back, so the caller opts in rather than out.\n ...(options.includeIds === false ? {} : { id: doc.id }),\n data: redactSecrets(doc.data()),\n }))\n}\n\n/** Read one document by id, or nothing when it does not exist. */\nasync function readDoc(\n db: any,\n collection: string,\n id: string,\n): Promise<ExportedDocument[]> {\n const snapshot = await db.collection(collection).doc(id).get()\n if (!snapshot.exists) return []\n return [{ id, data: redactSecrets(snapshot.data()) }]\n}\n\n/**\n * Walk a document and everything beneath it.\n *\n * `listCollections()` rather than a hard-coded subcollection list, for the\n * same reason the erasure uses `recursiveDelete`: a subcollection added next\n * quarter is reached without anybody remembering this file exists. Bounded by\n * `maxDepth` because the tree is customer-shaped and an unbounded recursion\n * inside a request is how AGL-1455 lost erasures to a 60-second timeout.\n */\nasync function readSubtree(\n db: any,\n ref: any,\n depth = 0,\n maxDepth = 4,\n): Promise<ExportedDocument[]> {\n const snapshot = await ref.get()\n const found: ExportedDocument[] = []\n if (snapshot.exists) {\n found.push({ path: ref.path, data: redactSecrets(snapshot.data()) })\n }\n if (depth >= maxDepth) return found\n const children = (await ref.listCollections?.()) ?? []\n for (const child of children) {\n const rows = await child.get()\n for (const doc of rows.docs) {\n found.push(...(await readSubtree(db, doc.ref, depth + 1, maxDepth)))\n }\n }\n return found\n}\n\nexport interface ExportOptions {\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n /** Stops a pathological workspace from building an unbounded file. */\n maxDepth?: number\n}\n\n/**\n * Everything we hold about a PERSON, as the person themselves may ask for it\n * (GDPR Art. 15 and Art. 20).\n *\n * Scoped to them, never to the workspaces they belong to. Their membership row\n * in each org is theirs; the roster around it is their colleagues'. An org\n * export is a separate call with a separate authorization, which is the whole\n * reason the two are different functions rather than one with a flag.\n */\nexport async function exportUserData(\n uid: string,\n options: ExportOptions = {},\n): Promise<PersonalDataExport> {\n const db = options.firestore ?? firestoreApp()\n const data: Record<string, ExportedDocument[]> = {}\n const counts: Record<string, number> = {}\n\n // The profile tree — `users/{uid}` and every subcollection under it.\n data['users'] = await readSubtree(\n db,\n db.collection('users').doc(uid),\n 0,\n options.maxDepth ?? 4,\n )\n\n // The public identity, which the user tree does not reach (AGL-1970).\n data['profiles'] = await readDoc(db, 'profiles', uid)\n\n // ONLY the messages this person wrote. The threads belong to a workspace\n // and carry other people's words; handing a member the whole conversation\n // to answer their own access request is a disclosure nobody consented to.\n const messages = await db\n .collectionGroup('messages')\n .where('authorId', '==', uid)\n .get()\n .catch(() => ({ docs: [] as any[] }))\n data['supportTickets'] = messages.docs.map((doc: any) => ({\n path: doc.ref.path,\n data: redactSecrets(doc.data()),\n }))\n\n return {\n subject: { type: 'user', id: uid },\n generatedAt: new Date().toISOString(),\n coverage: sourcesFor('user'),\n data,\n counts,\n elsewhere: ELSEWHERE,\n }\n}\n\n/**\n * Everything we hold about a WORKSPACE, for its owner (Art. 15 for the\n * personal data in it, and the \"export before you delete\" the Delete tab,\n * DPA §11 and Terms §13.3 have been promising).\n *\n * Every top-level collection the erasure sweeps by `orgId` is read here by the\n * same bound, so the two see the same rows. Where a source is withheld, its\n * COUNT is still reported: a collection that appears in neither the data nor\n * the counts is indistinguishable from one that does not exist, and that\n * ambiguity is what makes an incomplete export undetectable.\n */\nexport async function exportOrgData(\n orgId: string,\n options: ExportOptions = {},\n): Promise<PersonalDataExport> {\n const db = options.firestore ?? firestoreApp()\n const data: Record<string, ExportedDocument[]> = {}\n const counts: Record<string, number> = {}\n\n data['orgs'] = await readSubtree(\n db,\n db.collection('orgs').doc(orgId),\n 0,\n options.maxDepth ?? 4,\n )\n\n // Sites: the routing entry and the whole tree for each.\n const hosts = await db.collection('hosts').where('orgId', '==', orgId).get()\n const hostDocs: ExportedDocument[] = []\n const routing: ExportedDocument[] = []\n const outbox: ExportedDocument[] = []\n for (const host of hosts.docs) {\n hostDocs.push(...(await readSubtree(db, host.ref, 0, options.maxDepth ?? 4)))\n routing.push(...(await readDoc(db, 'hostIndex', host.id)))\n // Keyed by `hostId`, not `orgId`, so it is read here rather than in the\n // field-keyed loop below — the same bound `eraseHostSupplierDeliveries`\n // sweeps by, so the export and the erasure see the same rows.\n outbox.push(...(await readByField(db, 'supplierDeliveries', 'hostId', host.id)))\n }\n data['hosts'] = hostDocs\n data['hostIndex'] = routing\n data['supplierDeliveries'] = outbox\n\n // The field-keyed collections a path cascade is blind to — the ones the\n // erasure had to grow a sweep for, one issue at a time.\n for (const collection of [\n 'ssoDomains',\n CONSOLE_DOMAINS_COLLECTION,\n 'stripeCustomers',\n 'orgSlugs',\n 'publisherHandles',\n 'platformRevenue',\n 'storefrontTaxCollected',\n ]) {\n data[collection] = await readByField(db, collection, 'orgId', orgId)\n }\n\n // Existence, never the credential — and not the id either, which is the\n // token's SHA-256.\n data['apiKeys'] = await readByField(db, 'apiKeys', 'orgId', orgId, {\n includeIds: false,\n })\n\n // Which mailboxes the reps connected, never the grant behind one: the\n // tokens are withheld by `redactSecrets` like every other read here. The id\n // is the mailbox's own, so unlike `apiKeys` it stays.\n data['outreachMailboxCredentials'] = await readByField(\n db,\n 'outreachMailboxCredentials',\n 'orgId',\n orgId,\n )\n\n // Where each short tracking link the org's sequences sent points (AGL-3297).\n data['outreachLinks'] = await readByField(db, 'outreachLinks', 'orgId', orgId)\n\n // Shipping (AGL-3612), tax services (AGL-3631) and marketing platforms\n // (AGL-3639): top-level and keyed by `orgId`, the bound their\n // `orgKeyedCollections` erasure sweeps by. The sealed credentials are\n // withheld by `redactSecrets` on their names.\n for (const collection of [\n 'shippingTrackers',\n 'shippingQuoteCache',\n 'taxEngineConnections',\n 'taxEngineProductCodes',\n 'taxEngineExemptions',\n 'taxEngineTransactions',\n 'marketingPlatformConnections',\n 'marketingPlatformEvents',\n 'fulfillmentNetworkConnections',\n 'fulfillmentNetworkOrders',\n 'marketplaceConnections',\n 'marketplaceOrders',\n 'inventorySyncConnections',\n 'inventorySyncOrders',\n 'inventorySyncProducts',\n 'podConnections',\n 'podProductLinks',\n 'podOrders',\n 'deliveryAppStores',\n 'deliveryAppOrders',\n ]) {\n data[collection] = await readByField(db, collection, 'orgId', orgId)\n }\n\n data['publisherProfiles'] = await readDoc(db, 'publisherProfiles', orgId)\n data['marketplaceListings'] = await readByField(\n db,\n 'marketplaceListings',\n 'profileId',\n orgId,\n )\n\n // Support threads WITH their messages — for an org the whole thread is the\n // org's own record, unlike the person-scoped export above.\n const tickets = await db\n .collection('supportTickets')\n .where('orgId', '==', orgId)\n .get()\n const ticketDocs: ExportedDocument[] = []\n for (const ticket of tickets.docs) {\n ticketDocs.push(...(await readSubtree(db, ticket.ref, 0, 2)))\n }\n data['supportTickets'] = ticketDocs\n\n // Withheld, but counted — see the note on this function.\n const replayKeys = await db\n .collection('apiIdempotency')\n .where('orgId', '==', orgId)\n .get()\n counts['apiIdempotency'] = replayKeys.docs.length\n\n return {\n subject: { type: 'org', id: orgId },\n generatedAt: new Date().toISOString(),\n coverage: sourcesFor('org'),\n data,\n counts,\n elsewhere: ELSEWHERE,\n }\n}\n\n/** A filename a human can file: subject, id and the date it was produced. */\nexport function exportFilename(exported: PersonalDataExport): string {\n const day = exported.generatedAt.slice(0, 10)\n return `aglyn-${exported.subject.type}-data-${exported.subject.id}-${day}.json`\n}\n\nexport default exportUserData\n"],"names":["firebaseAdmin","CONSOLE_DOMAINS_COLLECTION","firestoreApp","app","firestore","PERSONAL_DATA_SOURCES","collection","keyedBy","subjects","exported","note","EXPORT_WITHHELD","filter","s","EXPORT_COVERED_COLLECTIONS","map","source","fieldNameTokens","key","String","replace","toLowerCase","split","Boolean","SECRET_WORDS","Set","SECRET_WORD_PAIRS","METERING_WORDS","SAFE_VALUE_WORDS","CREDENTIAL_PREFIXES","PEM_PATTERN","CRYPT_HASH_PATTERN","SALTED_HASH_PATTERN","JWT_PATTERN","UUID_PATTERN","OPAQUE_CHARSET","OPAQUE_MIN_LENGTH","OPAQUE_MAX_LENGTH","OPAQUE_MIN_ENTROPY","shannonEntropyPerChar","text","counts","Map","character","set","get","entropy","count","values","p","length","Math","log2","looksLikeCredential","value","trim","test","some","prefix","startsWith","REDACTED_URL_PARAM","URL_CREDENTIAL_PARAMS","scrubUrlCredentials","url","URL","changed","username","password","name","Array","from","searchParams","keys","has","toString","secretByName","tokens","hits","token","index","metering","every","nameMarksContentIdentifier","redactedMarker","present","redacted","reason","redactSecrets","undefined","scrubbed","isArray","entry","toDate","toISOString","toBase64","out","childKey","childValue","Object","entries","ELSEWHERE","sourcesFor","subject","includes","readByField","db","field","id","options","rows","where","docs","doc","includeIds","data","readDoc","snapshot","exists","readSubtree","ref","depth","maxDepth","found","push","path","children","listCollections","child","exportUserData","uid","messages","collectionGroup","catch","type","generatedAt","Date","coverage","elsewhere","exportOrgData","orgId","hosts","hostDocs","routing","outbox","host","tickets","ticketDocs","ticket","replayKeys","exportFilename","day","slice"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/personal-data-export.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Personal-data export — access and portability, in a machine-readable form\n * (AGL-1974).\n *\n * Privacy Policy §7 grants portability (GDPR Art. 20) and nothing in the\n * product exported a person's or a workspace's personal data. Six surfaces\n * told customers to \"export first\" before deleting — the org settings Delete\n * tab, the Close account card, `erase-org-cli.mjs`, the staff org actions,\n * live DPA §11 and Terms §13.3 — all pointing at something that did not\n * exist. An access request was assembled by a staff member reading the console\n * by hand.\n *\n * ## THE PROPERTY THIS FILE EXISTS FOR\n *\n * **An export whose coverage differs from erasure's is a bug in one of them.**\n * Both answer the same question — *what do we hold about this subject* — and\n * the honest failure modes are symmetric: an export that misses a collection\n * under-discloses on an access request, and an erasure that misses the same\n * one leaves data behind after we said it was gone. Historically the second\n * kept happening (AGL-1444, AGL-1448, AGL-1970, AGL-1971 each found a\n * collection outside the cascade), which is exactly the evidence that a list\n * kept in one place and mirrored by hand in another will drift.\n *\n * The ideal is one enumeration both derive from. That is not available here:\n * `erase.ts` is under concurrent change and its sweep list is expressed as\n * code, not data. So the coverage is enumerated ONCE below, as data, and\n * `personal-data-export-coverage.spec.ts` DISCOVERS every collection\n * `erase.ts` touches out of its source and fails when the two disagree. A new\n * sweep added to the erasure turns this file red until somebody decides\n * whether the export discloses it — which is the decision that was being\n * skipped.\n *\n * That guard is worth more than the feature.\n *\n * ## What must NOT come out wholesale\n *\n * Three categories, and each is a way an access request becomes a breach:\n *\n * 1. **Another person's data.** A workspace is shared. A member asking what\n * we hold about THEM gets their own roster row, not the roster; their own\n * support messages, not the thread's other authors. Handled by exporting\n * the person and the workspace as different subjects with different\n * scopes, never by filtering a single blob after the fact.\n * 2. **Secrets.** An `apiKeys` row confirms that a credential exists — its\n * label, scopes, creator and dates, which are all facts about the subject\n * — and never re-discloses the credential. Note the document ID *is* the\n * SHA-256 of the token, so the id itself is withheld too; a hash handed\n * back is a verifier handed back.\n * 3. **Staff-only fields.** Internal notes, override reasons and moderation\n * state are about our handling, not about the person, and several name a\n * staff member.\n *\n * ## What it is NOT\n *\n * Not `GET /api/hosts/export` — that is a Pro+ site-design backup whose own\n * header says it never includes bookings, leads, submissions or secrets, and\n * offering it as a portability answer would be misdescribing it. Not the\n * `firestore-export` cron, which is an unscoped DR snapshot. Not the audit CSV.\n */\n\nimport { firebaseAdmin } from './firebase-admin'\nimport { CONSOLE_DOMAINS_COLLECTION } from './console-domains'\n\nconst firestoreApp = () => firebaseAdmin.app().firestore()\n\n/** Which subject an export source belongs to. */\nexport type ExportSubject = 'user' | 'org'\n\n/** How a source is located — the axis a path cascade is blind to. */\nexport type ExportKeying =\n /** Top-level collection carrying the subject id in a FIELD. */\n | 'field'\n /** Top-level collection whose DOCUMENT ID is the subject id. */\n | 'doc-id'\n /** Under the subject's own document path. */\n | 'path'\n /** Reached through another collection's rows (a collection group). */\n | 'collection-group'\n /** Outside Firestore entirely. */\n | 'external'\n\nexport interface ExportSourceSpec {\n /** Firestore collection name, or an `external:` pseudo-name. */\n collection: string\n keyedBy: ExportKeying\n subjects: readonly ExportSubject[]\n /**\n * `true` when the export discloses it. `false` requires `reason` — and the\n * reason is the whole value of the field: it is a decision recorded at the\n * moment it was made, not an omission discovered later.\n */\n exported: boolean\n /** Why it is withheld, or how it is disclosed. Always present. */\n note: string\n}\n\n/**\n * Every collection `erase.ts` touches, and what the export does with it.\n *\n * Kept in the SAME order as the erasure's steps so a reader comparing the two\n * files reads them in step. `personal-data-export-coverage.spec.ts` proves the\n * set matches; this list carries the decision for each one.\n */\nexport const PERSONAL_DATA_SOURCES: readonly ExportSourceSpec[] = [\n // ── The person ────────────────────────────────────────────────────────\n {\n collection: 'users',\n keyedBy: 'doc-id',\n subjects: ['user'],\n exported: true,\n note: 'Profile: name, phone, postal address, legalAcceptances, and every subcollection (orgs, hostMemberships, notifications, passkeys).',\n },\n {\n collection: 'profiles',\n keyedBy: 'doc-id',\n subjects: ['user'],\n exported: true,\n note: 'The world-readable handle, display name and Stripe Connect account. Not reachable from the user tree (AGL-1970) — the same blind spot on both sides.',\n },\n // ── The workspace ─────────────────────────────────────────────────────\n {\n collection: 'orgs',\n keyedBy: 'doc-id',\n subjects: ['org'],\n exported: true,\n note: 'The org document and every subcollection: members, invites, roles, usage, apiUsage, analytics, datasets/records, contacts, lists, media, installs, activity, assistExchanges, retention.',\n },\n {\n collection: 'hosts',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Each site tree the org owns — pages, layouts, orders, form submissions. Secrets within are redacted, not omitted, so the export still says a webhook exists.',\n },\n {\n collection: 'hostIndex',\n keyedBy: 'doc-id',\n subjects: ['org'],\n exported: true,\n note: 'Subdomain/CNAME routing for each of the org’s sites. Configuration the customer set, so it is theirs to take.',\n },\n {\n collection: 'supplierDeliveries',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'The dropship outbox for each of the org’s sites, keyed by hostId (AGL-1448). Disclosed to the ORG and to no one else, and the reason is the unusual one: the personal data in the row is a SHOPPER’s — name, email and shipping address in the body we sign and POST to the supplier. That is the merchant’s own order record, which they already read in their console and for which they are the controller and we the processor, so an org access request is entitled to it. It is NOT a `user`-subject source: a console member asking what we hold about THEM is not the shopper, and returning these rows would answer their request with a third party’s address. Withholding it was not an option worth taking — `eraseHost` destroys these rows, so an export that omitted them would delete what it never disclosed.',\n },\n // ── Top-level, field-keyed: what a path cascade cannot see ────────────\n {\n collection: 'apiKeys',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'EXISTENCE ONLY — label, scopes, creating uid, dates. Never the credential, and never the document id, which is the SHA-256 of the token.',\n },\n {\n collection: 'outreachMailboxCredentials',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'EXISTENCE ONLY — which connected mailbox a credential authorizes, its provider, granted scopes and dates (AGL-2974), and since AGL-2978 the connected account’s address, its Google account id and the member who connected it: the organization’s own record of whose mailbox it holds. Every token is redacted by name and by shape (see redactSecrets) and never disclosed; the sealed refresh token and the id of the key that sealed it both carry `token` in their names. The document id is the mailbox id, not derived from any secret, so it is disclosed.',\n },\n {\n collection: 'outreachLinks',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Where each short tracking link in a sent Outreach sequence email points (AGL-3297): the enrollment, the step, the link’s index and the destination the step wrote. The organization’s own record of what it sent; the id is random and derived from no secret.',\n },\n {\n collection: 'shippingTrackers',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'One row per tracking number a label was bought for (AGL-3612): the site, the order record, the carrier, the tracking number and its latest status. The organization’s own record of what it shipped; the order it points at is already disclosed under its site. Org-only, for the reason `supplierDeliveries` gives: a tracking number locates a shopper’s parcel.',\n },\n {\n collection: 'shippingQuoteCache',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Carrier quotes for a site’s checkout, kept ten minutes (AGL-3612): the site, each service’s price and transit estimate, and when the row expires. No address is stored; the document id is a SHA-256 of the quoted request, an identifier rather than a verifier of any credential. Disclosed while it exists so the export does not omit a collection the erasure destroys.',\n },\n {\n collection: 'taxEngineConnections',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'EXISTENCE ONLY for the credential — each site’s connection to the merchant’s own Avalara AvaTax or TaxJar account (AGL-3631): provider, environment, account id and company code, ship-from address, default tax code, last test result and the member who connected it. The sealed API token and the id of the key that sealed it both carry `token` in their names and are redacted (see redactSecrets); the document id is the site id.',\n },\n {\n collection: 'taxEngineProductCodes',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'The tax code the merchant assigned each product at their tax service (AGL-3631). Catalog configuration the customer set, so it is theirs to take.',\n },\n {\n collection: 'taxEngineExemptions',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Customers the merchant recorded as tax-exempt (AGL-3631): email, name, exemption type, certificate number and the regions it covers. A SHOPPER’s data held for the merchant as controller, so disclosed to the ORG only, for the reason `supplierDeliveries` gives. The document id carries a truncated SHA-256 of the email — a lookup key, not a credential.',\n },\n {\n collection: 'taxEngineTransactions',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Each paid order as recorded with the merchant’s tax service, and the refunds reversed against it (AGL-3631): the sale sent (lines, ship-to address, the customer code, which is the shopper’s email), its status and last error. The merchant’s own tax record and a shopper’s address, so disclosed to the ORG only.',\n },\n {\n collection: 'marketingPlatformConnections',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'EXISTENCE ONLY for the credential — each site’s connection to the merchant’s own Mailchimp, Klaviyo, Omnisend or Attentive account (AGL-3639): provider, status, account name, the chosen list and tag, the sync switches, cursors and run timing. The sealed access and refresh tokens and the id of the key that sealed them carry `token` in their names and are redacted (see redactSecrets); the document id is the site id and the provider.',\n },\n {\n collection: 'marketingPlatformEvents',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Commerce events still owed to a connected marketing platform (AGL-3639): checkout started, order paid, fulfilled, refunded or cancelled, with the shopper’s email, the order, its items and value, and the tracking number. A SHOPPER’s data held for the merchant as controller, so disclosed to the ORG only, for the reason `supplierDeliveries` gives; a row is deleted once delivered.',\n },\n {\n collection: 'adConversionConnections',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'EXISTENCE ONLY for the credential — each site’s Conversions API connection to the merchant’s own Meta, TikTok or Pinterest ad account (AGL-3694): provider, status, the Pinterest ad account id, the test event code, and what was last sent or refused. The sealed access token and the id of the key that sealed it carry `token` in their names and are redacted (see redactSecrets); the document id is the site id and the provider.',\n },\n {\n collection: 'adConversionConsents',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'The advertising consent a shopper’s checkout carried, recorded only where it granted advertising on a site with a connection (AGL-3694): the consent status and when it was decided, the country, the page, the ad vendors’ browser ids, the IP address and the user agent. A SHOPPER’s data held for the merchant as controller, so disclosed to the ORG only; deleted when the order is reported or after 14 days.',\n },\n {\n collection: 'adConversionEvents',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Purchases and leads owed to a connected ad account (AGL-3694): the event id, order id, value and items, the visitor’s details as SHA-256 hashes, and the IP address, user agent and vendor browser ids. A SHOPPER’s data held for the merchant as controller, so disclosed to the ORG only; the personal fields are dropped once the event is sent, and every row expires after 14 days.',\n },\n {\n collection: 'fulfillmentNetworkConnections',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'EXISTENCE ONLY for the grant — each site’s connection to the merchant’s own ShipBob, ShipMonk or Amazon Multi-Channel Fulfillment account (AGL-3634, AGL-3697): network, status, account name, ShipBob channel, ShipMonk store id, Amazon marketplaces, routing and stock settings, the last stock count by SKU and totals. The sealed access and refresh tokens (for ShipMonk, the merchant’s own API key, sealed as the access token), the webhook token’s hash and the id of the key that sealed them carry `token` in their names, and ShipMonk’s sealed webhook signing secret carries `secret` in its name; all are redacted (see redactSecrets). The document id is the site id and the network.',\n },\n {\n collection: 'fulfillmentNetworkOrders',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Each paid order’s hand-off to a fulfillment network (AGL-3634): the order number, the lines sent with SKU and quantity, the network’s reference, the parcels read back with carrier, tracking number and status, and why anything was not sent. The merchant’s own fulfillment record of a SHOPPER’s order, so disclosed to the ORG only.',\n },\n {\n collection: 'courierConnections',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'EXISTENCE ONLY for the keys — each site’s connection to the merchant’s own DoorDash Drive developer account (AGL-3695): provider, the developer and key ids of its live and test keys, each key’s last test result, the store’s courier phone and note, and the member who connected it. The sealed signing secrets carry `secret` and the webhook token’s hash carries `token` in their names and are redacted (see redactSecrets); the document id is the site id and the provider.',\n },\n {\n collection: 'courierDeliveries',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Each local-delivery order’s courier (AGL-3695): the quote offered, the run booked and the runs before it — the courier’s reference, status, fee, arrival estimates, tracking link and any cancellation reason, and the member who booked it. No address or phone is stored here (they stay on the order); the merchant’s own record of a SHOPPER’s delivery, so disclosed to the ORG only.',\n },\n {\n collection: 'marketplaceConnections',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'EXISTENCE ONLY for the grant — each site’s connection to the merchant’s own Amazon, eBay, Etsy, TikTok Shop, Walmart or Faire seller account (AGL-3638): marketplace, status, account name, the marketplace sites the account sells on, the listing and order settings, the order cursor and the last sync’s counts. The sealed access and refresh tokens and the id of the key that sealed them carry `token` in their names and are redacted (see redactSecrets); the account’s marketplace ids are disclosed as stored. The document id is the site id and the marketplace. Its `log` and `listingState` subcollections (what each listing was last sent) are not read by this export.',\n },\n {\n collection: 'marketplaceOrders',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Each marketplace order the store imported (AGL-3638): the marketplace and its order id, the store order it became, which order line each marketplace line became, the marketplace’s fees, and each shipment confirmed back with carrier and tracking number. The merchant’s own record of a marketplace BUYER’s order, so disclosed to the ORG only; the buyer’s name and address are on the store’s order, not here.',\n },\n {\n collection: 'inventorySyncConnections',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'EXISTENCE ONLY for the keys — each site’s connection to the merchant’s own Cin7 Core, inFlow or Brightpearl account (AGL-3642): system, status, account name, the stock, product and order settings (location, the customer orders are recorded under, the tax rule), the last count by SKU, the last syncs’ counts, cursors and totals. The sealed API keys or grant and the id of the key that sealed them carry `credential` in their names and are redacted (see redactSecrets); Brightpearl’s account code and datacenter are disclosed as stored. The document id is the site id. Its `log` subcollection is not read by this export.',\n },\n {\n collection: 'inventorySyncOrders',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Each paid order’s hand-off to the connected inventory system (AGL-3642): the order number and our reference, the lines sent with SKU, quantity and price, the buyer’s name, email address and shipping address as sent, the totals, the system’s id and number for it, attempts and why it was not sent. The merchant’s own record of a SHOPPER’s order, so disclosed to the ORG only.',\n },\n {\n collection: 'inventorySyncProducts',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Each product linked between the store and the connected inventory system (AGL-3642): the system’s product id, the SKU, the version last synced, the store’s product and variant ids, and why it was not synced. Catalog data, no person’s.',\n },\n {\n collection: 'podConnections',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'EXISTENCE ONLY for the tokens — each site’s connection to the merchant’s own Printful store or Printify shop (AGL-3641): service, store id and name, currency, whether orders are sent automatically or held for review, whether prices sync, how shipments are heard about, and the last error. The sealed API token and webhook token and the id of the key that sealed them carry `token` in their names and are redacted (see redactSecrets).',\n },\n {\n collection: 'podProductLinks',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Each product imported from a print-on-demand service (AGL-3641): the service’s product and variant ids, the store product and variants it became, names, SKUs, what the service charges for each variant and the store’s price. Catalog data, no person’s.',\n },\n {\n collection: 'podOrders',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'The part of each paid order a print-on-demand service makes and ships (AGL-3641): the store order and its number, the service’s order id, the lines sent with quantity and cost, its status, costs, attempts and errors, and each parcel’s carrier and tracking number. No buyer name, address or email is kept here; they are on the store’s order. The merchant’s own record of a SHOPPER’s order, so disclosed to the ORG only.',\n },\n {\n collection: 'deliveryAppStores',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Each site’s link to the merchant’s own DoorDash, Uber Eats or Grubhub store (AGL-3644): the service, the store id it shows the merchant, the settings (accept automatically, prep minutes), the items matched to products and those waiting, and the menu’s last send. No credential is stored: every call is signed with the deployment’s partner credentials.',\n },\n {\n collection: 'deliveryAppOrders',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Each order a delivery service sent a site’s register (AGL-3644): the service and its order id and short code, the items with their options and instructions, the totals and refunds, where it stood at the counter, and the store order it became. The buyer appears by first name and last initial only. The merchant’s own record of a delivery BUYER’s order, so disclosed to the ORG only.',\n },\n {\n collection: 'zapierHooks',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Each Zapier REST hook a site’s API key subscribed (AGL-3643): the site, the events, the API key’s public id and name, delivery counts and dates. EXISTENCE ONLY for the URL — `targetUrl` is the capability Zapier minted for the Zap, a bearer URL whose secret is in its path, so it is redacted by name (see redactSecrets).',\n },\n {\n collection: 'zapierHookDeliveries',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Markers that one event reached one Zapier hook, so a retry does not post it twice (AGL-3643): the hook, the status and the time. The document id is a SHA-256 of the event and hook, an identifier rather than a verifier of any credential; no customer data, and a TTL policy removes each row.',\n },\n {\n collection: 'ssoDomains',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'The public SSO routing document: domain, GCIP tenant and provider ids, IdP display name. `token` is redacted.',\n },\n {\n collection: CONSOLE_DOMAINS_COLLECTION,\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Custom console domain claims the org holds.',\n },\n {\n collection: 'apiIdempotency',\n keyedBy: 'field',\n subjects: ['org'],\n exported: false,\n note: 'Replay keys. The document id is a SHA-256 of the org id and the caller’s Idempotency-Key, and the row names a record id and nothing about a person. Disclosed as a COUNT so the export does not silently omit a collection the erasure destroys.',\n },\n {\n collection: 'stripeCustomers',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'The local reverse index: which Stripe customer id belongs to this workspace. An identifier the customer already holds on their own invoices.',\n },\n {\n collection: 'orgSlugs',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Every workspace name the org has ever held, rename tombstones included — naming history is a fact about them.',\n },\n {\n collection: 'publisherHandles',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Marketplace handle reservations and rename tombstones (AGL-1970).',\n },\n {\n collection: 'publisherProfiles',\n keyedBy: 'doc-id',\n subjects: ['org'],\n exported: true,\n note: 'The public publisher identity — handle, display name, bio, website, avatar, publisher agreement. `stripeAccountId` is the org’s own payout destination and is disclosed; nothing here is a credential.',\n },\n {\n collection: 'marketplaceListings',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Listings the org published. These deliberately OUTLIVE an erasure (AGL-1448 Tier 3) — which makes disclosing them on an access request more important, not less: they are the part that does not go away.',\n },\n {\n collection: 'supportTickets',\n keyedBy: 'field',\n subjects: ['org', 'user'],\n exported: true,\n note: 'For an ORG: every thread and its messages. For a PERSON: only the messages they themselves authored — a thread has other authors, and handing a member the whole conversation would disclose colleagues’ words to them.',\n },\n // ── Retained past erasure on statutory grounds ────────────────────────\n {\n collection: 'platformRevenue',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Per-transaction tax filing records. GDPR Art. 17(3)(b) exempts them from ERASURE (AGL-1811); it does not exempt them from ACCESS. If a transaction of theirs is in there it is disclosable — and the export is the only place a customer can see what survived their erasure.',\n },\n {\n collection: 'storefrontTaxCollected',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Sales tax charged to shoppers on the org’s storefront (AGL-1904). Same statutory retention, same access obligation.',\n },\n // ── Records of our own handling ───────────────────────────────────────\n {\n collection: 'adminAudit',\n keyedBy: 'field',\n subjects: ['org', 'user'],\n exported: false,\n note: 'STAFF-ONLY. Rows name the staff actor, carry override reasons and internal notes, and are the record of our handling rather than data about the subject. `/admin/audit` → Export CSV serves a request that specifically asks for them, after a staff read.',\n },\n]\n\n/** Sources this export deliberately withholds, with the reason. */\nexport const EXPORT_WITHHELD = PERSONAL_DATA_SOURCES.filter((s) => !s.exported)\n\n/** Collection names the export claims to cover, for the coverage guard. */\nexport const EXPORT_COVERED_COLLECTIONS: readonly string[] =\n PERSONAL_DATA_SOURCES.map((source) => source.collection)\n\n/**\n * ## WHY THIS IS SHAPE-DRIVEN AND NOT A LIST OF NAMES\n *\n * It used to be a list of names, anchored on separators:\n *\n * /(^|[._-])(secret|token|password|apikey|…)($|[._-])/i\n *\n * That pattern requires a `.`, `_`, `-`, or a string boundary on both sides\n * of the word, so it reads `password_hash` and `webhooks.secret` and misses\n * `supplierToken` and `passwordScrypt` — the exact two fields the AGL-1881\n * review found leaving the platform in a customer's own export file. One is\n * a live bearer credential a supplier authenticates with; the other is a\n * shopper's scrypt password hash. **A denylist of names is always one naming\n * convention behind, and camelCase is the proof.**\n *\n * So the name half is now tokenized rather than separator-anchored, and it is\n * no longer the only gate. Three run, in order, and any of them redacts:\n *\n * * **(A) The name, tokenized.** `supplierToken` splits to\n * `['supplier','token']` the same way `supplier_token` and\n * `SUPPLIER-TOKEN` do, so the convention stops mattering. A name matches\n * on a single secret word or on an adjacent pair (`client secret`).\n * * **(B) The VALUE's shape, whatever the key is called.** This is the half\n * that does not need to know the schema: a PEM block, a JWT, a vendor\n * prefix (`sk_live_`, `whsec_`, `ghp_`, `AKIA`…), a `$2b$`/`$argon2` hash,\n * this repo's own `salt:hash` scrypt form, or an opaque high-entropy\n * string is a credential whether the field is called `supplierToken`,\n * `t`, or `notes`. **Gate B is the \"redact unless known safe\" default**:\n * a credential-shaped string is withheld unless its key names it as a\n * content identifier (see {@link SAFE_VALUE_WORDS}).\n * * **(C) Credentials carried inside a URL.** `?token=`, `?sig=`,\n * `?key=`, and `https://user:pass@host` are stripped from the URL rather\n * than the URL being blanked, because a media object's `url` field really\n * does carry a live Firebase download token in a query parameter, and the\n * location of the customer's own file is exactly what an access request\n * asks for. Precision beats blanking here.\n *\n * ### Why the KEY half is still deny-based, when the value half is not\n *\n * A true allowlist of field names is not available on this data. The export\n * walks documents whose field names our customers author — canvas nodes,\n * dataset records, custom metadata, form submissions — so an allowlist of\n * names would blank most of the file and turn an access request into a\n * non-answer, which is the under-disclosure this feature exists to stop. The\n * allowlist therefore lives where the vocabulary IS ours: value shapes. An\n * unrecognized VALUE shape is passed through (it is prose, a number, a date,\n * a customer's own content); an unrecognized value shape that looks like a\n * credential is withheld. That is the inversion the review asked for, put\n * where it can hold.\n *\n * ### The one deliberate carve-out\n *\n * `token`/`tokens` is also a metering word — `tokensUsed: 14203` on an\n * assist exchange is a usage count, and blanking it would blank a real\n * disclosure. A credential is a STRING; a count is a number. So a numeric or\n * boolean value under a name that matched only on a metering word passes\n * through. Nothing else in the vocabulary gets that treatment.\n *\n * A false positive costs a redacted field in an export. A false negative\n * costs a credential. Everything below is tuned in that direction.\n */\n\n/**\n * Split a field name into lowercase word tokens, convention-independently.\n *\n * `supplierToken`, `supplier_token`, `SUPPLIER-TOKEN` and `Supplier.Token`\n * all become `['supplier','token']`. Acronym runs split at the last capital\n * (`SSOTokenId` → `['sso','token','id']`) and digits break a word\n * (`apiKey2` → `['api','key','2']`), so a numbered field cannot hide.\n */\nexport function fieldNameTokens(key: unknown): string[] {\n return String(key ?? '')\n .replace(/[^A-Za-z0-9]+/g, ' ')\n .replace(/([a-z0-9])([A-Z])/g, '$1 $2')\n .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')\n .replace(/([A-Za-z])([0-9])/g, '$1 $2')\n .toLowerCase()\n .split(' ')\n .filter(Boolean)\n}\n\n/**\n * Words that make a field a credential on their own.\n *\n * Deliberately short. Every entry here is a word that has no innocent\n * meaning on a stored VALUE — `hmac`, `scrypt` and `bcrypt` name the\n * algorithm that produced a verifier, `passphrase` and `credential` name the\n * thing itself. `key` is NOT here and must never be: `publicKey` on a\n * passkey and `sortKey` on a dataset are not secrets, which is what\n * {@link SECRET_WORD_PAIRS} is for.\n */\nconst SECRET_WORDS = new Set([\n 'secret',\n 'secrets',\n 'password',\n 'passwords',\n 'passwd',\n 'pwd',\n 'passphrase',\n 'token',\n 'tokens',\n 'credential',\n 'credentials',\n 'apikey',\n 'privatekey',\n 'scrypt',\n 'bcrypt',\n 'argon',\n 'argon2',\n 'pbkdf2',\n 'hmac',\n 'otp',\n 'totp',\n 'authorization',\n])\n\n/**\n * Words that are a credential only next to another word.\n *\n * `key` is meaningless alone and decisive after `api`, `private`, `signing`,\n * `secret`, `session` or `encryption`; `token` after `access`/`refresh` is\n * already caught by {@link SECRET_WORDS} and listed here anyway so the pair\n * table reads as the complete statement of the rule.\n */\nconst SECRET_WORD_PAIRS = new Set([\n 'api key',\n 'private key',\n 'signing key',\n 'secret key',\n 'session key',\n 'encryption key',\n 'client secret',\n 'access token',\n 'refresh token',\n 'webhook secret',\n 'shared secret',\n // A Stripe payment link is a live bearer URL: whoever holds it can pay.\n // Named in the AGL-1443 inventory of what the old dump actually carried.\n 'payment link',\n // A Zapier REST hook's `targetUrl` (AGL-3643) is the same kind of bearer\n // URL: whoever holds it can post into the Zap, and its secret is in the\n // path, which the URL scrub does not read.\n 'target url',\n 'auth code',\n 'recovery code',\n 'backup code',\n])\n\n/**\n * The metering carve-out — see the header. A name that matched ONLY on one\n * of these, over a number or a boolean, is a count and is disclosed.\n */\nconst METERING_WORDS = new Set(['token', 'tokens'])\n\n/**\n * Key words that mark a high-entropy value as a content identifier rather\n * than a credential, defusing gate (B) only.\n *\n * A SHA-256 has exactly the shape of a 64-character secret and is not one.\n * Gate (A) is never defused by this list: `apiKeyId` is redacted, because\n * the name says credential and an id of a credential is not worth the\n * argument.\n */\nconst SAFE_VALUE_WORDS = new Set([\n // A passkey's `publicKey` is public by definition and has exactly the shape\n // of a secret. Safe to list because gate (A) is never defused here — a\n // `publicToken` is still redacted, on the strength of `token`.\n 'public',\n 'id',\n 'ids',\n 'uid',\n 'uuid',\n 'guid',\n 'hash',\n 'hashes',\n 'sha',\n 'sha1',\n 'sha256',\n 'sha512',\n 'md5',\n 'crc',\n 'crc32c',\n 'etag',\n 'checksum',\n 'digest',\n 'fingerprint',\n 'generation',\n 'version',\n 'revision',\n 'slug',\n 'path',\n 'url',\n 'uri',\n 'href',\n 'src',\n 'color',\n 'colour',\n 'hex',\n])\n\n/** Vendor credential prefixes — unambiguous, and worth naming explicitly. */\nconst CREDENTIAL_PREFIXES = [\n 'sk_live_',\n 'sk_test_',\n 'rk_live_',\n 'rk_test_',\n 'whsec_',\n 'ghp_',\n 'gho_',\n 'ghu_',\n 'ghs_',\n 'ghr_',\n 'github_pat_',\n 'xoxb-',\n 'xoxp-',\n 'xapp-',\n 'xoxa-',\n 'AKIA',\n 'ASIA',\n 'AIza',\n 'ya29.',\n 'SG.',\n 're_',\n 'shpat_',\n 'shpss_',\n 'npm_',\n 'glpat-',\n 'sq0csp-',\n 'sq0atp-',\n]\n\n/** A PEM block that carries a private key or a certificate request. */\nconst PEM_PATTERN = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/\n\n/** Modular crypt format: bcrypt, argon2, scrypt, PBKDF2, sha512-crypt. */\nconst CRYPT_HASH_PATTERN = /^\\$(2[abxy]?|argon2[a-z]*|scrypt|pbkdf2[^$]*|[56])\\$/\n\n/** This repo's own scrypt storage form: `{16+ hex salt}:{32+ hex hash}`. */\nconst SALTED_HASH_PATTERN = /^[0-9a-f]{16,}:[0-9a-f]{32,}$/i\n\n/** A JSON Web Token — three base64url segments. */\nconst JWT_PATTERN = /^[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}$/\n\n/** A UUID, which is an identifier far more often than it is a credential. */\nconst UUID_PATTERN =\n /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\n/** Charset of an opaque credential: base64, base64url or hex, nothing else. */\nconst OPAQUE_CHARSET = /^[A-Za-z0-9_+/=-]+$/\n\n/**\n * The window an opaque secret lives in.\n *\n * Below 32 characters the false-positive rate against ordinary identifiers is\n * not worth it and the value is too short to be a modern credential anyway.\n * Above 512 it is customer CONTENT — a base64 image, a compressed canvas\n * blob, an encoded document — and blanking those is the \"gutting the export\"\n * failure this file is explicitly trying not to commit.\n */\nconst OPAQUE_MIN_LENGTH = 32\nconst OPAQUE_MAX_LENGTH = 512\n\n/** Shannon entropy per character. Prose and slugs sit well below this. */\nconst OPAQUE_MIN_ENTROPY = 3.2\n\nfunction shannonEntropyPerChar(text: string): number {\n const counts = new Map<string, number>()\n for (const character of text) {\n counts.set(character, (counts.get(character) ?? 0) + 1)\n }\n let entropy = 0\n for (const count of counts.values()) {\n const p = count / text.length\n entropy -= p * Math.log2(p)\n }\n return entropy\n}\n\n/**\n * Does this STRING look like a credential, ignoring what it is called?\n *\n * Exported because the guard spec asserts each limb independently — a shape\n * detector tested only through the redactor is a detector whose gaps are\n * invisible.\n */\nexport function looksLikeCredential(value: string): boolean {\n const text = value.trim()\n if (!text) return false\n if (PEM_PATTERN.test(text)) return true\n if (CRYPT_HASH_PATTERN.test(text)) return true\n if (SALTED_HASH_PATTERN.test(text)) return true\n if (CREDENTIAL_PREFIXES.some((prefix) => text.startsWith(prefix))) return true\n if (JWT_PATTERN.test(text)) return true\n // Anything with whitespace is prose, and `data:` is an inline asset. Both\n // are customer content, and neither is a bearer credential.\n if (/\\s/.test(text)) return false\n if (/^data:/i.test(text)) return false\n if (UUID_PATTERN.test(text)) return false\n if (text.length < OPAQUE_MIN_LENGTH || text.length > OPAQUE_MAX_LENGTH) {\n return false\n }\n if (!OPAQUE_CHARSET.test(text)) return false\n return shannonEntropyPerChar(text) >= OPAQUE_MIN_ENTROPY\n}\n\n/** What a stripped URL parameter reads as, so the removal is visible. */\nexport const REDACTED_URL_PARAM = 'REDACTED'\n\n/** Query parameters that carry a bearer credential inside a URL. */\nconst URL_CREDENTIAL_PARAMS = new Set([\n 'token',\n 'access_token',\n 'accesstoken',\n 'id_token',\n 'refresh_token',\n 'auth',\n 'authorization',\n 'apikey',\n 'api_key',\n 'key',\n 'sig',\n 'signature',\n 'secret',\n 'password',\n 'downloadtoken',\n])\n\n/**\n * Strip the credential out of a URL, keeping the URL.\n *\n * A media object's `url` is `…/o/{path}?alt=media&token={uuid}` — a live,\n * unauthenticated download credential, in a field an access request has a\n * real interest in. Blanking the whole value would tell the customer nothing\n * about where their own file is; keeping it whole hands out the credential.\n * So the parameter is replaced and the rest survives. `user:pass@` userinfo\n * goes the same way.\n *\n * Returns the input unchanged when it is not a URL — every non-URL string\n * has already been decided by the shape gate.\n */\nexport function scrubUrlCredentials(value: string): string {\n if (!/^https?:\\/\\//i.test(value)) return value\n let url: URL\n try {\n url = new URL(value)\n } catch {\n return value\n }\n let changed = false\n if (url.username || url.password) {\n url.username = ''\n url.password = ''\n changed = true\n }\n for (const name of Array.from(url.searchParams.keys())) {\n if (!URL_CREDENTIAL_PARAMS.has(name.toLowerCase())) continue\n if (!url.searchParams.get(name)) continue\n url.searchParams.set(name, REDACTED_URL_PARAM)\n changed = true\n }\n return changed ? url.toString() : value\n}\n\n/**\n * Gate (A): does the NAME say credential?\n *\n * `value` is consulted only for the metering carve-out described in the\n * header — a `tokensUsed: 14203` is a count, not a credential.\n */\nexport function secretByName(key: unknown, value?: unknown): boolean {\n const tokens = fieldNameTokens(key)\n if (!tokens.length) return false\n const hits = tokens.filter((token) => SECRET_WORDS.has(token))\n for (let index = 0; index + 1 < tokens.length; index += 1) {\n if (SECRET_WORD_PAIRS.has(`${tokens[index]} ${tokens[index + 1]}`)) {\n return true\n }\n }\n if (!hits.length) return false\n // Matched only on a metering word, over a number or a boolean: a count.\n const metering = hits.every((token) => METERING_WORDS.has(token))\n if (metering && (typeof value === 'number' || typeof value === 'boolean')) {\n return false\n }\n return true\n}\n\n/** Gate (B)'s defuser: does the NAME say this high-entropy value is an id? */\nfunction nameMarksContentIdentifier(key: unknown): boolean {\n return fieldNameTokens(key).some((token) => SAFE_VALUE_WORDS.has(token))\n}\n\n/** What a redacted value is replaced with — presence, never content. */\nexport interface RedactedValue {\n redacted: true\n /** Whether a value was actually there, so absence is distinguishable. */\n present: boolean\n reason: 'secret'\n}\n\nconst redactedMarker = (present: boolean): RedactedValue => ({\n redacted: true,\n present,\n reason: 'secret',\n})\n\n/**\n * Deep-copy a document, replacing every credential-shaped value with a\n * presence marker.\n *\n * Presence rather than deletion, because \"we hold a webhook secret for you\"\n * is itself a true and disclosable fact, and a silently-absent key would make\n * the export look like it holds less than it does — the under-disclosure this\n * feature exists to stop, reintroduced by the safety measure.\n *\n * Firestore values that are not plain JSON (Timestamp, GeoPoint, DocumentRef,\n * Buffer) are stringified rather than walked: they carry no nested keys to\n * redact, and `JSON.stringify` on a Timestamp yields `{_seconds,_nanoseconds}`,\n * which is not a date to a human reading the file.\n *\n * Three gates, any of which redacts — the name (A), the value's shape (B),\n * and a credential embedded in a URL (C). See the header above\n * {@link fieldNameTokens} for why the value half is the one that carries the\n * \"withhold unless known safe\" default.\n */\nexport function redactSecrets(value: unknown, key = ''): unknown {\n // (A) The name, tokenized — convention-independent.\n if (key && secretByName(key, value)) {\n return redactedMarker(value !== undefined && value !== null && value !== '')\n }\n if (value === null || value === undefined) return value ?? null\n if (typeof value === 'string') {\n // (C) A URL that carries its credential in a parameter keeps the URL and\n // loses the credential — see {@link scrubUrlCredentials}.\n const scrubbed = scrubUrlCredentials(value)\n if (scrubbed !== value) return scrubbed\n // (B) The shape, whatever the field is called. Withheld unless the name\n // marks it as a content identifier.\n if (looksLikeCredential(value) && !nameMarksContentIdentifier(key)) {\n return redactedMarker(true)\n }\n return value\n }\n if (Array.isArray(value)) return value.map((entry) => redactSecrets(entry, key))\n if (typeof value === 'object') {\n const source = value as Record<string, unknown>\n // Firestore Timestamp — the only non-plain value common enough to be\n // worth rendering rather than dumping.\n if (typeof (source as any).toDate === 'function') {\n try {\n return (source as any).toDate().toISOString()\n } catch {\n return String(value)\n }\n }\n if (typeof (source as any).toBase64 === 'function') return '[binary]'\n const out: Record<string, unknown> = {}\n for (const [childKey, childValue] of Object.entries(source)) {\n out[childKey] = redactSecrets(childValue, childKey)\n }\n return out\n }\n return value\n}\n\nexport interface ExportedDocument {\n /** Document id — omitted where the id is itself a secret (see `apiKeys`). */\n id?: string\n path?: string\n data: unknown\n}\n\nexport interface PersonalDataExport {\n subject: { type: ExportSubject; id: string }\n generatedAt: string\n /**\n * The disclosure decision for every source, copied into the file the\n * customer receives. An export that silently omits a collection is\n * indistinguishable from one that has nothing to report; this makes the two\n * different, and it is what a supervisory authority would ask to see.\n */\n coverage: readonly ExportSourceSpec[]\n /** Collection name → the documents disclosed for it. */\n data: Record<string, ExportedDocument[]>\n /** Collection name → row count, for sources disclosed as a count only. */\n counts: Record<string, number>\n /** Places we hold data that is not in Firestore. */\n elsewhere: readonly string[]\n}\n\n/** Data we hold outside Firestore — named, because a file that omits them reads as complete. */\nconst ELSEWHERE = [\n 'Stripe — customer object, payment methods, invoices. Request from Stripe or ask us.',\n 'Resend — transactional email delivery logs.',\n 'Google Analytics — 14-month retention, not keyed to an account.',\n 'Cloud Storage — uploaded media and avatars, under users/{uid}/, orgs/{orgId}/ and hosts/{hostId}/. Listed by path in this file; the bytes are served by their own URLs.',\n]\n\nfunction sourcesFor(subject: ExportSubject): ExportSourceSpec[] {\n return PERSONAL_DATA_SOURCES.filter((source) => source.subjects.includes(subject))\n}\n\n/** Read a top-level collection bounded by a field equal to the subject id. */\nasync function readByField(\n db: any,\n collection: string,\n field: string,\n id: string,\n options: { includeIds?: boolean } = {},\n): Promise<ExportedDocument[]> {\n const rows = await db.collection(collection).where(field, '==', id).get()\n return rows.docs.map((doc: any) => ({\n // `apiKeys` document ids are the SHA-256 of the token: an id handed back\n // is a verifier handed back, so the caller opts in rather than out.\n ...(options.includeIds === false ? {} : { id: doc.id }),\n data: redactSecrets(doc.data()),\n }))\n}\n\n/** Read one document by id, or nothing when it does not exist. */\nasync function readDoc(\n db: any,\n collection: string,\n id: string,\n): Promise<ExportedDocument[]> {\n const snapshot = await db.collection(collection).doc(id).get()\n if (!snapshot.exists) return []\n return [{ id, data: redactSecrets(snapshot.data()) }]\n}\n\n/**\n * Walk a document and everything beneath it.\n *\n * `listCollections()` rather than a hard-coded subcollection list, for the\n * same reason the erasure uses `recursiveDelete`: a subcollection added next\n * quarter is reached without anybody remembering this file exists. Bounded by\n * `maxDepth` because the tree is customer-shaped and an unbounded recursion\n * inside a request is how AGL-1455 lost erasures to a 60-second timeout.\n */\nasync function readSubtree(\n db: any,\n ref: any,\n depth = 0,\n maxDepth = 4,\n): Promise<ExportedDocument[]> {\n const snapshot = await ref.get()\n const found: ExportedDocument[] = []\n if (snapshot.exists) {\n found.push({ path: ref.path, data: redactSecrets(snapshot.data()) })\n }\n if (depth >= maxDepth) return found\n const children = (await ref.listCollections?.()) ?? []\n for (const child of children) {\n const rows = await child.get()\n for (const doc of rows.docs) {\n found.push(...(await readSubtree(db, doc.ref, depth + 1, maxDepth)))\n }\n }\n return found\n}\n\nexport interface ExportOptions {\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n /** Stops a pathological workspace from building an unbounded file. */\n maxDepth?: number\n}\n\n/**\n * Everything we hold about a PERSON, as the person themselves may ask for it\n * (GDPR Art. 15 and Art. 20).\n *\n * Scoped to them, never to the workspaces they belong to. Their membership row\n * in each org is theirs; the roster around it is their colleagues'. An org\n * export is a separate call with a separate authorization, which is the whole\n * reason the two are different functions rather than one with a flag.\n */\nexport async function exportUserData(\n uid: string,\n options: ExportOptions = {},\n): Promise<PersonalDataExport> {\n const db = options.firestore ?? firestoreApp()\n const data: Record<string, ExportedDocument[]> = {}\n const counts: Record<string, number> = {}\n\n // The profile tree — `users/{uid}` and every subcollection under it.\n data['users'] = await readSubtree(\n db,\n db.collection('users').doc(uid),\n 0,\n options.maxDepth ?? 4,\n )\n\n // The public identity, which the user tree does not reach (AGL-1970).\n data['profiles'] = await readDoc(db, 'profiles', uid)\n\n // ONLY the messages this person wrote. The threads belong to a workspace\n // and carry other people's words; handing a member the whole conversation\n // to answer their own access request is a disclosure nobody consented to.\n const messages = await db\n .collectionGroup('messages')\n .where('authorId', '==', uid)\n .get()\n .catch(() => ({ docs: [] as any[] }))\n data['supportTickets'] = messages.docs.map((doc: any) => ({\n path: doc.ref.path,\n data: redactSecrets(doc.data()),\n }))\n\n return {\n subject: { type: 'user', id: uid },\n generatedAt: new Date().toISOString(),\n coverage: sourcesFor('user'),\n data,\n counts,\n elsewhere: ELSEWHERE,\n }\n}\n\n/**\n * Everything we hold about a WORKSPACE, for its owner (Art. 15 for the\n * personal data in it, and the \"export before you delete\" the Delete tab,\n * DPA §11 and Terms §13.3 have been promising).\n *\n * Every top-level collection the erasure sweeps by `orgId` is read here by the\n * same bound, so the two see the same rows. Where a source is withheld, its\n * COUNT is still reported: a collection that appears in neither the data nor\n * the counts is indistinguishable from one that does not exist, and that\n * ambiguity is what makes an incomplete export undetectable.\n */\nexport async function exportOrgData(\n orgId: string,\n options: ExportOptions = {},\n): Promise<PersonalDataExport> {\n const db = options.firestore ?? firestoreApp()\n const data: Record<string, ExportedDocument[]> = {}\n const counts: Record<string, number> = {}\n\n data['orgs'] = await readSubtree(\n db,\n db.collection('orgs').doc(orgId),\n 0,\n options.maxDepth ?? 4,\n )\n\n // Sites: the routing entry and the whole tree for each.\n const hosts = await db.collection('hosts').where('orgId', '==', orgId).get()\n const hostDocs: ExportedDocument[] = []\n const routing: ExportedDocument[] = []\n const outbox: ExportedDocument[] = []\n for (const host of hosts.docs) {\n hostDocs.push(...(await readSubtree(db, host.ref, 0, options.maxDepth ?? 4)))\n routing.push(...(await readDoc(db, 'hostIndex', host.id)))\n // Keyed by `hostId`, not `orgId`, so it is read here rather than in the\n // field-keyed loop below — the same bound `eraseHostSupplierDeliveries`\n // sweeps by, so the export and the erasure see the same rows.\n outbox.push(...(await readByField(db, 'supplierDeliveries', 'hostId', host.id)))\n }\n data['hosts'] = hostDocs\n data['hostIndex'] = routing\n data['supplierDeliveries'] = outbox\n\n // The field-keyed collections a path cascade is blind to — the ones the\n // erasure had to grow a sweep for, one issue at a time.\n for (const collection of [\n 'ssoDomains',\n CONSOLE_DOMAINS_COLLECTION,\n 'stripeCustomers',\n 'orgSlugs',\n 'publisherHandles',\n 'platformRevenue',\n 'storefrontTaxCollected',\n ]) {\n data[collection] = await readByField(db, collection, 'orgId', orgId)\n }\n\n // Existence, never the credential — and not the id either, which is the\n // token's SHA-256.\n data['apiKeys'] = await readByField(db, 'apiKeys', 'orgId', orgId, {\n includeIds: false,\n })\n\n // Which mailboxes the reps connected, never the grant behind one: the\n // tokens are withheld by `redactSecrets` like every other read here. The id\n // is the mailbox's own, so unlike `apiKeys` it stays.\n data['outreachMailboxCredentials'] = await readByField(\n db,\n 'outreachMailboxCredentials',\n 'orgId',\n orgId,\n )\n\n // Where each short tracking link the org's sequences sent points (AGL-3297).\n data['outreachLinks'] = await readByField(db, 'outreachLinks', 'orgId', orgId)\n\n // Shipping (AGL-3612), tax services (AGL-3631) and marketing platforms\n // (AGL-3639): top-level and keyed by `orgId`, the bound their\n // `orgKeyedCollections` erasure sweeps by. The sealed credentials are\n // withheld by `redactSecrets` on their names.\n for (const collection of [\n 'shippingTrackers',\n 'shippingQuoteCache',\n 'taxEngineConnections',\n 'taxEngineProductCodes',\n 'taxEngineExemptions',\n 'taxEngineTransactions',\n 'marketingPlatformConnections',\n 'marketingPlatformEvents',\n 'adConversionConnections',\n 'adConversionConsents',\n 'adConversionEvents',\n 'fulfillmentNetworkConnections',\n 'fulfillmentNetworkOrders',\n 'courierConnections',\n 'courierDeliveries',\n 'marketplaceConnections',\n 'marketplaceOrders',\n 'inventorySyncConnections',\n 'inventorySyncOrders',\n 'inventorySyncProducts',\n 'podConnections',\n 'podProductLinks',\n 'podOrders',\n 'deliveryAppStores',\n 'deliveryAppOrders',\n ]) {\n data[collection] = await readByField(db, collection, 'orgId', orgId)\n }\n\n data['publisherProfiles'] = await readDoc(db, 'publisherProfiles', orgId)\n data['marketplaceListings'] = await readByField(\n db,\n 'marketplaceListings',\n 'profileId',\n orgId,\n )\n\n // Support threads WITH their messages — for an org the whole thread is the\n // org's own record, unlike the person-scoped export above.\n const tickets = await db\n .collection('supportTickets')\n .where('orgId', '==', orgId)\n .get()\n const ticketDocs: ExportedDocument[] = []\n for (const ticket of tickets.docs) {\n ticketDocs.push(...(await readSubtree(db, ticket.ref, 0, 2)))\n }\n data['supportTickets'] = ticketDocs\n\n // Withheld, but counted — see the note on this function.\n const replayKeys = await db\n .collection('apiIdempotency')\n .where('orgId', '==', orgId)\n .get()\n counts['apiIdempotency'] = replayKeys.docs.length\n\n return {\n subject: { type: 'org', id: orgId },\n generatedAt: new Date().toISOString(),\n coverage: sourcesFor('org'),\n data,\n counts,\n elsewhere: ELSEWHERE,\n }\n}\n\n/** A filename a human can file: subject, id and the date it was produced. */\nexport function exportFilename(exported: PersonalDataExport): string {\n const day = exported.generatedAt.slice(0, 10)\n return `aglyn-${exported.subject.type}-data-${exported.subject.id}-${day}.json`\n}\n\nexport default exportUserData\n"],"names":["firebaseAdmin","CONSOLE_DOMAINS_COLLECTION","firestoreApp","app","firestore","PERSONAL_DATA_SOURCES","collection","keyedBy","subjects","exported","note","EXPORT_WITHHELD","filter","s","EXPORT_COVERED_COLLECTIONS","map","source","fieldNameTokens","key","String","replace","toLowerCase","split","Boolean","SECRET_WORDS","Set","SECRET_WORD_PAIRS","METERING_WORDS","SAFE_VALUE_WORDS","CREDENTIAL_PREFIXES","PEM_PATTERN","CRYPT_HASH_PATTERN","SALTED_HASH_PATTERN","JWT_PATTERN","UUID_PATTERN","OPAQUE_CHARSET","OPAQUE_MIN_LENGTH","OPAQUE_MAX_LENGTH","OPAQUE_MIN_ENTROPY","shannonEntropyPerChar","text","counts","Map","character","set","get","entropy","count","values","p","length","Math","log2","looksLikeCredential","value","trim","test","some","prefix","startsWith","REDACTED_URL_PARAM","URL_CREDENTIAL_PARAMS","scrubUrlCredentials","url","URL","changed","username","password","name","Array","from","searchParams","keys","has","toString","secretByName","tokens","hits","token","index","metering","every","nameMarksContentIdentifier","redactedMarker","present","redacted","reason","redactSecrets","undefined","scrubbed","isArray","entry","toDate","toISOString","toBase64","out","childKey","childValue","Object","entries","ELSEWHERE","sourcesFor","subject","includes","readByField","db","field","id","options","rows","where","docs","doc","includeIds","data","readDoc","snapshot","exists","readSubtree","ref","depth","maxDepth","found","push","path","children","listCollections","child","exportUserData","uid","messages","collectionGroup","catch","type","generatedAt","Date","coverage","elsewhere","exportOrgData","orgId","hosts","hostDocs","routing","outbox","host","tickets","ticketDocs","ticket","replayKeys","exportFilename","day","slice"],"mappings":"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"}
|