@aglyn/tenant-data-admin 1.0.0-beta.199 → 1.0.0-beta.200

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/admin-audit-write.d.ts +28 -0
  3. package/src/lib/server/admin-audit-write.js +46 -0
  4. package/src/lib/server/admin-audit-write.js.map +1 -0
  5. package/src/lib/server/admin-audit.d.ts +4 -9
  6. package/src/lib/server/admin-audit.js +12 -31
  7. package/src/lib/server/admin-audit.js.map +1 -1
  8. package/src/lib/server/consent-group-carry.js +2 -0
  9. package/src/lib/server/consent-group-carry.js.map +1 -1
  10. package/src/lib/server/contact-company-link.js +3 -0
  11. package/src/lib/server/contact-company-link.js.map +1 -1
  12. package/src/lib/server/contact-lifecycle-floor.js +3 -0
  13. package/src/lib/server/contact-lifecycle-floor.js.map +1 -1
  14. package/src/lib/server/contact-merge.js +4 -0
  15. package/src/lib/server/contact-merge.js.map +1 -1
  16. package/src/lib/server/crm-booking-activity.js +3 -2
  17. package/src/lib/server/crm-booking-activity.js.map +1 -1
  18. package/src/lib/server/crm-records.d.ts +15 -0
  19. package/src/lib/server/crm-records.js +52 -0
  20. package/src/lib/server/crm-records.js.map +1 -1
  21. package/src/lib/server/csp-aggregate.d.ts +16 -0
  22. package/src/lib/server/csp-aggregate.js +36 -0
  23. package/src/lib/server/csp-aggregate.js.map +1 -1
  24. package/src/lib/server/duplicate-resource.js +12 -5
  25. package/src/lib/server/duplicate-resource.js.map +1 -1
  26. package/src/lib/server/email-delivery-log.d.ts +33 -0
  27. package/src/lib/server/email-delivery-log.js +43 -2
  28. package/src/lib/server/email-delivery-log.js.map +1 -1
  29. package/src/lib/server/email-suppression.d.ts +11 -14
  30. package/src/lib/server/email-suppression.js +14 -31
  31. package/src/lib/server/email-suppression.js.map +1 -1
  32. package/src/lib/server/erase-person.js +22 -3
  33. package/src/lib/server/erase-person.js.map +1 -1
  34. package/src/lib/server/erase.js +4 -3
  35. package/src/lib/server/erase.js.map +1 -1
  36. package/src/lib/server/host-memberships.d.ts +34 -0
  37. package/src/lib/server/host-memberships.js +89 -1
  38. package/src/lib/server/host-memberships.js.map +1 -1
  39. package/src/lib/server/host-visitor-records.js +23 -3
  40. package/src/lib/server/host-visitor-records.js.map +1 -1
  41. package/src/lib/server/list-members.d.ts +11 -0
  42. package/src/lib/server/list-members.js +28 -1
  43. package/src/lib/server/list-members.js.map +1 -1
  44. package/src/lib/server/list-query.d.ts +36 -0
  45. package/src/lib/server/list-query.js +49 -0
  46. package/src/lib/server/list-query.js.map +1 -0
  47. package/src/lib/server/media-tombstone.js +8 -2
  48. package/src/lib/server/media-tombstone.js.map +1 -1
  49. package/src/lib/server/org-member-list-fields.d.ts +31 -0
  50. package/src/lib/server/org-member-list-fields.js +38 -0
  51. package/src/lib/server/org-member-list-fields.js.map +1 -0
  52. package/src/lib/server/organizations.js +37 -7
  53. package/src/lib/server/organizations.js.map +1 -1
  54. package/src/lib/server/replay-erasures.js +2 -1
  55. package/src/lib/server/replay-erasures.js.map +1 -1
  56. package/src/lib/server/sso-enforcement.js +2 -1
  57. package/src/lib/server/sso-enforcement.js.map +1 -1
  58. package/src/lib/server/upsert-contact.js +8 -4
  59. package/src/lib/server/upsert-contact.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/list-members.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 * The only writer of `orgs/{orgId}/lists/{listId}/members` (AGL-2499).\n *\n * Two enrollment routes reach this collection — the commerce newsletter\n * handler and the workflow `enrollList` step — and they used to derive the\n * document id two incompatible ways: a full `sha256(email)` on one side, a\n * `hmac('aglyn-list-member', email)` truncated to 20 hex on the other. The\n * same person subscribing by both routes became two members of one list.\n *\n * The id is now `personKey` on both, which is the derivation both\n * `docs/specs/email-overhaul.md` §3d and `docs/specs/reusable-forms.md` §4\n * specify, and it normalizes before hashing so casing cannot fork it either.\n *\n * ## Why a helper and not two corrected call sites\n *\n * Two call sites that merely agree today are what produced the split: nothing\n * about either one said the other existed. A third route — the reusable-forms\n * capture path is already specified — would have been written the same way.\n * Enrolling goes through this function so that the id has one definition and\n * no caller is offered the chance to derive its own.\n */\n\nimport type { DocumentReference } from 'firebase-admin/firestore'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n normalizeContactEmail,\n personKey,\n CAPTURED_BY_HOST_FIELD,\n MARKETING_CONSENT_BASIS_FIELD,\n MARKETING_CONSENT_BY_HOST_FIELD,\n MARKETING_CONSENT_FIELD,\n marketingConsentFieldsForGroup,\n readMarketingBasis,\n type ConsentGroup,\n} from '@aglyn/aglyn/server'\nimport { consentGroupForGrant } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { createHash, createHmac } from 'node:crypto'\n\n/**\n * The two ids this collection was written under before `personKey`.\n *\n * Read-only, and never written for a new row: they exist so an address\n * already enrolled under a legacy id is *found* rather than duplicated. See\n * `enrollListMember` for why that lookup is the migration.\n *\n * Both take the normalized address, which is what the two original call sites\n * happened to pass — each lowercased at its own entry point before hashing, so\n * every row already on the collection was keyed from a lowercased address even\n * though neither derivation enforced it.\n */\nfunction legacyListMemberIds(normalizedEmail: string): string[] {\n return [\n createHash('sha256').update(normalizedEmail).digest('hex'),\n createHmac('sha256', 'aglyn-list-member')\n .update(normalizedEmail)\n .digest('hex')\n .slice(0, 20),\n ]\n}\n\n/**\n * Every document id one address may be filed under on a list's `members`:\n * the canonical key first, then the two ids earlier writers minted. A\n * reader that must find the person wherever they were enrolled — the\n * erasure sweep (AGL-2623) — asks for all of them with one `getAll`, the\n * same set `enrollListMember` reads before it writes, so a member enrolled\n * under an old id is neither missed nor duplicated.\n */\nexport function listMemberDocIds(email: unknown): string[] {\n const normalized = normalizeContactEmail(email)\n if (!normalized) return []\n const key = personKey(normalized)\n if (!key) return []\n return [key, ...legacyListMemberIds(normalized).filter((id) => id !== key)]\n}\n\n/**\n * What makes an enrollment mailable, and who is answerable for it.\n *\n * Two values and no third. They are not interchangeable and the difference is\n * the whole reason the basis is stored rather than implied:\n *\n * - `contact-opt-in` — the person ticked a box. The enrollment carries a\n * decision they made, and `atMs` is when THEY made it, not when the row was\n * written. Nobody is answerable because nobody asserted anything.\n * - `operator-attested` — a console account stated that they have this\n * person's permission. That statement is itself the basis, so it is only\n * worth storing with the account that made it and the moment they did.\n *\n * Rendering the second as though it were the first is what a support or\n * compliance question cannot recover from, which is why the basis rides\n * beside `marketingConsent` on every row that has one.\n */\nexport type ListMemberConsentBasis = 'contact-opt-in' | 'operator-attested'\n\n/** A basis, with the attribution that makes it answerable. */\nexport interface ListMemberConsent {\n basis: ListMemberConsentBasis\n /**\n * When the basis was recorded.\n *\n * For a pass-through this is the ORIGINAL opt-in moment carried over from\n * the person's own record, because the question a consent record answers is\n * when the person said yes — not when somebody copied their answer onto a\n * list.\n */\n atMs: number\n /** The console account that attested. Meaningless for a pass-through. */\n byUid?: string | null\n /**\n * Why, in the operator's own terms, for whoever audits this later.\n *\n * Written for an attestation and ignored for a pass-through, which needs no\n * explanation beyond the person's own record. An import fills it from what\n * the FILE declared — the opt-in source and date the merchant supplied per\n * row — because \"a declared basis per address, not one checkbox over the\n * file\" is only worth asking for if the declaration survives onto the row\n * it was made about.\n */\n reason?: string\n}\n\nexport interface EnrollListMemberInput {\n /** `orgs/{orgId}/lists/{listId}` — the caller has already proved it exists. */\n listRef: DocumentReference\n /**\n * The consent group the enrollment is being made IN THE NAME OF.\n *\n * Lists live on the org and every site in it can mail one, so a membership\n * carries a basis that no path could attribute to a controller. A GROUP\n * rather than a site, because a business running three sites under one name\n * legitimately enrolls somebody into all three at once — and an agency's\n * client, which declared no group, gets exactly itself.\n *\n * Required and undefaulted: a caller that has not resolved a group has not\n * decided who this person agreed to hear from.\n */\n group: ConsentGroup\n email: string\n name?: string\n /** Free-form provenance: `'newsletter'`, `'action:{actionId}'`. */\n source: string\n /**\n * Explicit marketing opt-in, with a consent timestamp — the same shape\n * `upsertHostContact` and `addHostLead` already carry.\n *\n * A list membership had NO consent field of any kind, so `audience: 'list'`\n * had nothing for the send-time join to read\n * (`docs/specs/email-overhaul.md` §1d/§3f). This is the field that closes\n * it, and it is the caller's captured checkbox: enrolling is an act, and\n * `false`/omitted therefore writes nothing rather than a basis.\n */\n marketingConsent?: boolean\n /**\n * The basis in full, for a caller that knows more than \"a box was ticked\".\n *\n * `marketingConsent: true` is the shorthand a capture surface uses: it means\n * `contact-opt-in`, recorded now, asserted by nobody. Anything else — a\n * pass-through carrying the person's ORIGINAL opt-in date, or an\n * attestation carrying the account that made it — has to say so, and this\n * is where it says it. The two inputs are normalized to one value before\n * anything is written, so there is a single writer of the consent fields.\n */\n consent?: ListMemberConsent\n /**\n * The person's own grants, as `grantEntriesAsRecorded` read them off their\n * record — what a PASS-THROUGH carries (AGL-3320).\n *\n * A `contact-opt-in` consent with these writes exactly these sites, with\n * each grant's own moment and the disclosure it was given under: an opt-in\n * given to one site before the group existed stays that site's on the list,\n * and one given under the group's sentence reaches the sites it named.\n * Re-recording the pass-through over {@link group} instead would widen the\n * first to every site the group names today.\n */\n grantEntries?: Readonly<Record<string, Readonly<Record<string, unknown>>>>\n /**\n * The `consentGroupDisclosureKey` a capture surface rendered beside its\n * checkbox, for a {@link marketingConsent} captured just now. The grant\n * pools across {@link group} only when it is the group's current key.\n *\n * Every other basis without {@link grantEntries} — an attestation, a\n * capture that rendered no disclosure — is recorded for the enrolling site\n * alone: an operator's word about one site's list is not a disclosure\n * anybody read about the rest.\n */\n disclosedConsentGroup?: string | null\n /**\n * How the person got here: enrolled by hand or by an automation\n * (`'manual'`), or selected by a dynamic list's rule (`'rule'`).\n *\n * The materializer needs to tell its own rows apart from a manual one on\n * the same list, because a person who stops matching the rule leaves and a\n * person somebody added by hand does not.\n */\n via?: 'manual' | 'rule'\n}\n\nexport interface EnrolledListMember {\n /** The document actually written — a legacy id when one was adopted. */\n memberId: string\n /** True when an existing legacy-keyed row was written instead of a new one. */\n adopted: boolean\n /** False when the row already existed under some id. */\n created: boolean\n}\n\n/** Why nothing was written. */\nexport type EnrollListMemberRefusal =\n /** Not an address this collection can key. Nothing to write, nothing to fix. */\n | 'unusable-address'\n /**\n * The person's membership records a REFUSAL. Not overridable by any caller\n * and not reported as a failure to fix, because the fix would be to ignore\n * it.\n */\n | 'declined'\n\n/**\n * Enrolled, or refused and why.\n *\n * A union rather than `EnrolledListMember | null`, because the two refusals\n * are not the same event and a caller that cannot tell them apart is a caller\n * that reports a person's recorded refusal as a malformed address.\n */\nexport type EnrollListMemberResult =\n | ({ enrolled: true } & EnrolledListMember)\n | { enrolled: false; refusal: EnrollListMemberRefusal }\n\n/**\n * Enrolls one address into a list, at one document per person.\n *\n * ## The legacy lookup is the migration\n *\n * Changing the derivation without one would strand every row written under the\n * old ids: the next enrollment of an address already on the list would key a\n * *new* document beside the old one, so a defect that produced duplicates only\n * when two routes met would start producing them on a single route. Rather\n * than rewrite those ids — a bulk operation that has to delete the row it\n * replaces, and deleting an enrollment destroys the consent record that says\n * the person asked to be there — the write resolves the person's existing\n * document and keeps using it.\n *\n * The cost is one `getAll` of three refs, on a human-triggered signup, in\n * place of the blind `set` this replaced. It is one round trip, not three.\n *\n * `addedAt` is stamped only when the document is created, so re-enrolling\n * keeps the date the person actually joined — the same \"earliest wins\" collapse\n * `docs/specs/email-overhaul.md` §3d asks a backfill to preserve.\n *\n * ## A recorded refusal stops every route through this function\n *\n * This is the only writer of the collection, so the refusal check belongs\n * here and not at the four call sites: a guard on the button that adds\n * somebody by hand leaves the newsletter handler, the workflow `enrollList`\n * step and the dynamic-list materializer free to put the same person back.\n * The membership snapshot is already in hand from the id resolution above, so\n * the check costs no read.\n *\n * It answers the question about the LIST ROW. A refusal recorded on the\n * person's CRM record is a different document this function does not fetch —\n * a caller that can see one is expected to consult it, and the send-time\n * consent join reads the row this function writes either way.\n */\nexport async function enrollListMember(\n input: EnrollListMemberInput,\n): Promise<EnrollListMemberResult> {\n const email = normalizeContactEmail(input.email)\n if (!email) return { enrolled: false, refusal: 'unusable-address' }\n const key = personKey(email)\n if (!key) return { enrolled: false, refusal: 'unusable-address' }\n\n const members = input.listRef.collection('members')\n const canonicalRef = members.doc(key)\n const legacyRefs = legacyListMemberIds(email)\n // A legacy derivation that happens to agree with `personKey` — `sha256` of\n // the same normalized address does — must not be fetched twice: `getAll`\n // rejects duplicate references.\n .filter((id) => id !== key)\n .map((id) => members.doc(id))\n\n const snapshots = await input.listRef.firestore.getAll(\n canonicalRef,\n ...legacyRefs,\n )\n const existing = snapshots.find((snapshot) => snapshot.exists)\n const target = existing?.ref ?? canonicalRef\n\n if (\n existing &&\n readMarketingBasis(existing.data() as Record<string, unknown>, input.group)\n .basis === 'declined'\n ) {\n return { enrolled: false, refusal: 'declined' }\n }\n\n /*\n * ONE value from the two ways a caller may state a basis.\n *\n * `marketingConsent: true` is a capture surface saying \"a box was ticked\n * just now\", which is `contact-opt-in` recorded at this moment with nobody\n * asserting it. Normalizing here rather than branching at the write is what\n * keeps the consent fields to a single writer — two branches stamping\n * overlapping subsets of them is how a row comes to carry a basis with no\n * timestamp, or a timestamp with no basis.\n */\n const consent: ListMemberConsent | null =\n input.consent ??\n (input.marketingConsent\n ? { basis: 'contact-opt-in', atMs: Date.now() }\n : null)\n\n await target.set(\n {\n email,\n ...(input.name ? { name: input.name } : {}),\n source: input.source,\n ...(input.via ? { via: input.via } : {}),\n /*\n * Written only when the caller has a basis, and never unwritten.\n *\n * A merge that stamped `marketingConsent: false` on the omitted case\n * would erase a basis this person gave on an earlier enrollment, and a\n * withdrawn consent is not the same event as a re-enrollment that\n * happened to carry no checkbox. Withdrawal has its own path — the\n * unsubscribe link and the suppression list.\n *\n * `marketingConsentAtMs` moves with each fresh basis on purpose: the\n * question a consent record answers is when the person last said yes.\n *\n * The `marketingConsent` family rather than the `consent*` names\n * `docs/specs/email-overhaul.md` §3d sketched: `readMarketingBasis` is\n * the shipped reader and it reads these, so a second vocabulary here\n * would be a basis the send-time join cannot see. The two new fields\n * are attribution ON that field — they say why the person is mailable,\n * never whether — so nothing about the send decision moves into them.\n *\n * Written UNDER the enrolling GROUP, through the one writer helper. A\n * list lives on the org and every site in it can mail one, so a basis\n * written at the top of this row would enroll a person into one\n * client's newsletter and make them mailable by all of them. A group of\n * one — the default — is that client and nobody else.\n *\n * `marketingConsentByUid` is written on EVERY basis, `null` for a\n * pass-through. Leaving it absent would let a real opt-in that\n * supersedes an earlier attestation inherit the attesting account, and\n * a row reading \"this account vouched for them\" when the person ticked\n * a box themselves is a false attribution in the one direction a\n * compliance answer cannot afford.\n *\n * `marketingConsentReason` follows the same rule for the same reason:\n * always written, `''` for a pass-through, so a later real opt-in\n * cannot inherit the sentence an earlier import wrote about where the\n * merchant said the address came from.\n *\n * WHICH sites the basis covers is `membershipConsentFields`' to say,\n * and it never re-derives a grant from the group as it stands now.\n */\n ...(consent ? membershipConsentFields(input, consent) : {}),\n ...(existing\n ? {}\n : {\n addedAt: FieldValue.serverTimestamp(),\n [CAPTURED_BY_HOST_FIELD]: [input.group.hostId],\n }),\n },\n { merge: true },\n )\n\n return {\n enrolled: true,\n memberId: target.id,\n adopted: target.id !== key,\n created: !existing,\n }\n}\n\n/**\n * The consent fields one enrollment writes: the basis, its attribution, and\n * the sites it covers (AGL-3320).\n *\n * Three ways in, and the sites follow from which one it is:\n *\n * - a PASS-THROUGH with the person's {@link EnrollListMemberInput.grantEntries}\n * writes those sites and no others, each grant with its own moment and\n * the disclosure it was given under;\n * - a capture's checkbox pools over the group only when its surface sent\n * back the group's current disclosure key — `consentGroupForGrant`;\n * - everything else, an attestation among them, is the enrolling site's\n * alone.\n *\n * The attribution is the membership's own on every entry, the pass-through's\n * included, for the reason the write explains: an entry that kept an earlier\n * attestation's account under a person's own opt-in would be a false claim\n * about who vouched for them.\n */\nfunction membershipConsentFields(\n input: EnrollListMemberInput,\n consent: ListMemberConsent,\n): Record<string, unknown> {\n const attribution = {\n [MARKETING_CONSENT_BASIS_FIELD]: consent.basis,\n marketingConsentByUid: consent.byUid ?? null,\n marketingConsentReason: consent.reason ?? '',\n }\n const carried =\n consent.basis === 'contact-opt-in'\n ? Object.entries(input.grantEntries ?? {})\n : []\n if (carried.length) {\n return {\n [MARKETING_CONSENT_BY_HOST_FIELD]: Object.fromEntries(\n carried.map(([hostId, entry]) => [\n hostId,\n {\n ...entry,\n [MARKETING_CONSENT_FIELD]: true,\n // The grant's own moment; the pass-through's only when the\n // person's record never stamped one, as the basis itself falls\n // back to now.\n marketingConsentAtMs: entry['marketingConsentAtMs'] ?? consent.atMs,\n ...attribution,\n },\n ]),\n ),\n }\n }\n return marketingConsentFieldsForGroup(\n consentGroupForGrant(input.group, input.disclosedConsentGroup),\n consent.atMs,\n attribution,\n )\n}\n"],"names":["FieldValue","normalizeContactEmail","personKey","CAPTURED_BY_HOST_FIELD","MARKETING_CONSENT_BASIS_FIELD","MARKETING_CONSENT_BY_HOST_FIELD","MARKETING_CONSENT_FIELD","marketingConsentFieldsForGroup","readMarketingBasis","consentGroupForGrant","createHash","createHmac","legacyListMemberIds","normalizedEmail","update","digest","slice","listMemberDocIds","email","normalized","key","filter","id","enrollListMember","input","enrolled","refusal","members","listRef","collection","canonicalRef","doc","legacyRefs","map","snapshots","firestore","getAll","existing","find","snapshot","exists","target","ref","data","group","basis","consent","marketingConsent","atMs","Date","now","set","name","source","via","membershipConsentFields","addedAt","serverTimestamp","hostId","merge","memberId","adopted","created","attribution","marketingConsentByUid","byUid","marketingConsentReason","reason","carried","Object","entries","grantEntries","length","fromEntries","entry","marketingConsentAtMs","disclosedConsentGroup"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/list-members.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 * The only writer of `orgs/{orgId}/lists/{listId}/members` (AGL-2499).\n *\n * Two enrollment routes reach this collection — the commerce newsletter\n * handler and the workflow `enrollList` step — and they used to derive the\n * document id two incompatible ways: a full `sha256(email)` on one side, a\n * `hmac('aglyn-list-member', email)` truncated to 20 hex on the other. The\n * same person subscribing by both routes became two members of one list.\n *\n * The id is now `personKey` on both, which is the derivation both\n * `docs/specs/email-overhaul.md` §3d and `docs/specs/reusable-forms.md` §4\n * specify, and it normalizes before hashing so casing cannot fork it either.\n *\n * ## Why a helper and not two corrected call sites\n *\n * Two call sites that merely agree today are what produced the split: nothing\n * about either one said the other existed. A third route — the reusable-forms\n * capture path is already specified — would have been written the same way.\n * Enrolling goes through this function so that the id has one definition and\n * no caller is offered the chance to derive its own.\n */\n\nimport type { DocumentReference } from 'firebase-admin/firestore'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n normalizeContactEmail,\n personKey,\n CAPTURED_BY_HOST_FIELD,\n MARKETING_CONSENT_BASIS_FIELD,\n MARKETING_CONSENT_BY_HOST_FIELD,\n MARKETING_CONSENT_FIELD,\n marketingConsentFieldsForGroup,\n readMarketingBasis,\n type ConsentGroup,\n} from '@aglyn/aglyn/server'\nimport { consentGroupForGrant } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { createHash, createHmac } from 'node:crypto'\nimport { emailSearchTokens } from './email-suppression'\n\n/**\n * What a list's membership table searches a member by (AGL-3321): the word\n * prefixes of their name and of their address (`emailSearchTokens`), one\n * array, so one `array-contains` finds \"dana\" in either.\n *\n * Mirrored by `tools/scripts/backfill-email-list-filters.mjs`, which stamps\n * the rows written before the field; the two are held to the same fixtures\n * (`tools/scripts/lib/email-search-tokens.fixtures.json`, which the\n * script-side twin in `tools/scripts/lib/email-search-tokens.mjs` answers too).\n */\nexport function listMemberSearchTokens(\n email: string | null | undefined,\n name: string | null | undefined,\n): string[] {\n return [...new Set([...emailSearchTokens(email), ...nameSearchTokens(name)])]\n}\n\n/**\n * The two ids this collection was written under before `personKey`.\n *\n * Read-only, and never written for a new row: they exist so an address\n * already enrolled under a legacy id is *found* rather than duplicated. See\n * `enrollListMember` for why that lookup is the migration.\n *\n * Both take the normalized address, which is what the two original call sites\n * happened to pass — each lowercased at its own entry point before hashing, so\n * every row already on the collection was keyed from a lowercased address even\n * though neither derivation enforced it.\n */\nfunction legacyListMemberIds(normalizedEmail: string): string[] {\n return [\n createHash('sha256').update(normalizedEmail).digest('hex'),\n createHmac('sha256', 'aglyn-list-member')\n .update(normalizedEmail)\n .digest('hex')\n .slice(0, 20),\n ]\n}\n\n/**\n * Every document id one address may be filed under on a list's `members`:\n * the canonical key first, then the two ids earlier writers minted. A\n * reader that must find the person wherever they were enrolled — the\n * erasure sweep (AGL-2623) — asks for all of them with one `getAll`, the\n * same set `enrollListMember` reads before it writes, so a member enrolled\n * under an old id is neither missed nor duplicated.\n */\nexport function listMemberDocIds(email: unknown): string[] {\n const normalized = normalizeContactEmail(email)\n if (!normalized) return []\n const key = personKey(normalized)\n if (!key) return []\n return [key, ...legacyListMemberIds(normalized).filter((id) => id !== key)]\n}\n\n/**\n * What makes an enrollment mailable, and who is answerable for it.\n *\n * Two values and no third. They are not interchangeable and the difference is\n * the whole reason the basis is stored rather than implied:\n *\n * - `contact-opt-in` — the person ticked a box. The enrollment carries a\n * decision they made, and `atMs` is when THEY made it, not when the row was\n * written. Nobody is answerable because nobody asserted anything.\n * - `operator-attested` — a console account stated that they have this\n * person's permission. That statement is itself the basis, so it is only\n * worth storing with the account that made it and the moment they did.\n *\n * Rendering the second as though it were the first is what a support or\n * compliance question cannot recover from, which is why the basis rides\n * beside `marketingConsent` on every row that has one.\n */\nexport type ListMemberConsentBasis = 'contact-opt-in' | 'operator-attested'\n\n/** A basis, with the attribution that makes it answerable. */\nexport interface ListMemberConsent {\n basis: ListMemberConsentBasis\n /**\n * When the basis was recorded.\n *\n * For a pass-through this is the ORIGINAL opt-in moment carried over from\n * the person's own record, because the question a consent record answers is\n * when the person said yes — not when somebody copied their answer onto a\n * list.\n */\n atMs: number\n /** The console account that attested. Meaningless for a pass-through. */\n byUid?: string | null\n /**\n * Why, in the operator's own terms, for whoever audits this later.\n *\n * Written for an attestation and ignored for a pass-through, which needs no\n * explanation beyond the person's own record. An import fills it from what\n * the FILE declared — the opt-in source and date the merchant supplied per\n * row — because \"a declared basis per address, not one checkbox over the\n * file\" is only worth asking for if the declaration survives onto the row\n * it was made about.\n */\n reason?: string\n}\n\nexport interface EnrollListMemberInput {\n /** `orgs/{orgId}/lists/{listId}` — the caller has already proved it exists. */\n listRef: DocumentReference\n /**\n * The consent group the enrollment is being made IN THE NAME OF.\n *\n * Lists live on the org and every site in it can mail one, so a membership\n * carries a basis that no path could attribute to a controller. A GROUP\n * rather than a site, because a business running three sites under one name\n * legitimately enrolls somebody into all three at once — and an agency's\n * client, which declared no group, gets exactly itself.\n *\n * Required and undefaulted: a caller that has not resolved a group has not\n * decided who this person agreed to hear from.\n */\n group: ConsentGroup\n email: string\n name?: string\n /** Free-form provenance: `'newsletter'`, `'action:{actionId}'`. */\n source: string\n /**\n * Explicit marketing opt-in, with a consent timestamp — the same shape\n * `upsertHostContact` and `addHostLead` already carry.\n *\n * A list membership had NO consent field of any kind, so `audience: 'list'`\n * had nothing for the send-time join to read\n * (`docs/specs/email-overhaul.md` §1d/§3f). This is the field that closes\n * it, and it is the caller's captured checkbox: enrolling is an act, and\n * `false`/omitted therefore writes nothing rather than a basis.\n */\n marketingConsent?: boolean\n /**\n * The basis in full, for a caller that knows more than \"a box was ticked\".\n *\n * `marketingConsent: true` is the shorthand a capture surface uses: it means\n * `contact-opt-in`, recorded now, asserted by nobody. Anything else — a\n * pass-through carrying the person's ORIGINAL opt-in date, or an\n * attestation carrying the account that made it — has to say so, and this\n * is where it says it. The two inputs are normalized to one value before\n * anything is written, so there is a single writer of the consent fields.\n */\n consent?: ListMemberConsent\n /**\n * The person's own grants, as `grantEntriesAsRecorded` read them off their\n * record — what a PASS-THROUGH carries (AGL-3320).\n *\n * A `contact-opt-in` consent with these writes exactly these sites, with\n * each grant's own moment and the disclosure it was given under: an opt-in\n * given to one site before the group existed stays that site's on the list,\n * and one given under the group's sentence reaches the sites it named.\n * Re-recording the pass-through over {@link group} instead would widen the\n * first to every site the group names today.\n */\n grantEntries?: Readonly<Record<string, Readonly<Record<string, unknown>>>>\n /**\n * The `consentGroupDisclosureKey` a capture surface rendered beside its\n * checkbox, for a {@link marketingConsent} captured just now. The grant\n * pools across {@link group} only when it is the group's current key.\n *\n * Every other basis without {@link grantEntries} — an attestation, a\n * capture that rendered no disclosure — is recorded for the enrolling site\n * alone: an operator's word about one site's list is not a disclosure\n * anybody read about the rest.\n */\n disclosedConsentGroup?: string | null\n /**\n * How the person got here: enrolled by hand or by an automation\n * (`'manual'`), or selected by a dynamic list's rule (`'rule'`).\n *\n * The materializer needs to tell its own rows apart from a manual one on\n * the same list, because a person who stops matching the rule leaves and a\n * person somebody added by hand does not.\n */\n via?: 'manual' | 'rule'\n}\n\nexport interface EnrolledListMember {\n /** The document actually written — a legacy id when one was adopted. */\n memberId: string\n /** True when an existing legacy-keyed row was written instead of a new one. */\n adopted: boolean\n /** False when the row already existed under some id. */\n created: boolean\n}\n\n/** Why nothing was written. */\nexport type EnrollListMemberRefusal =\n /** Not an address this collection can key. Nothing to write, nothing to fix. */\n | 'unusable-address'\n /**\n * The person's membership records a REFUSAL. Not overridable by any caller\n * and not reported as a failure to fix, because the fix would be to ignore\n * it.\n */\n | 'declined'\n\n/**\n * Enrolled, or refused and why.\n *\n * A union rather than `EnrolledListMember | null`, because the two refusals\n * are not the same event and a caller that cannot tell them apart is a caller\n * that reports a person's recorded refusal as a malformed address.\n */\nexport type EnrollListMemberResult =\n | ({ enrolled: true } & EnrolledListMember)\n | { enrolled: false; refusal: EnrollListMemberRefusal }\n\n/**\n * Enrolls one address into a list, at one document per person.\n *\n * ## The legacy lookup is the migration\n *\n * Changing the derivation without one would strand every row written under the\n * old ids: the next enrollment of an address already on the list would key a\n * *new* document beside the old one, so a defect that produced duplicates only\n * when two routes met would start producing them on a single route. Rather\n * than rewrite those ids — a bulk operation that has to delete the row it\n * replaces, and deleting an enrollment destroys the consent record that says\n * the person asked to be there — the write resolves the person's existing\n * document and keeps using it.\n *\n * The cost is one `getAll` of three refs, on a human-triggered signup, in\n * place of the blind `set` this replaced. It is one round trip, not three.\n *\n * `addedAt` is stamped only when the document is created, so re-enrolling\n * keeps the date the person actually joined — the same \"earliest wins\" collapse\n * `docs/specs/email-overhaul.md` §3d asks a backfill to preserve.\n *\n * ## A recorded refusal stops every route through this function\n *\n * This is the only writer of the collection, so the refusal check belongs\n * here and not at the four call sites: a guard on the button that adds\n * somebody by hand leaves the newsletter handler, the workflow `enrollList`\n * step and the dynamic-list materializer free to put the same person back.\n * The membership snapshot is already in hand from the id resolution above, so\n * the check costs no read.\n *\n * It answers the question about the LIST ROW. A refusal recorded on the\n * person's CRM record is a different document this function does not fetch —\n * a caller that can see one is expected to consult it, and the send-time\n * consent join reads the row this function writes either way.\n */\nexport async function enrollListMember(\n input: EnrollListMemberInput,\n): Promise<EnrollListMemberResult> {\n const email = normalizeContactEmail(input.email)\n if (!email) return { enrolled: false, refusal: 'unusable-address' }\n const key = personKey(email)\n if (!key) return { enrolled: false, refusal: 'unusable-address' }\n\n const members = input.listRef.collection('members')\n const canonicalRef = members.doc(key)\n const legacyRefs = legacyListMemberIds(email)\n // A legacy derivation that happens to agree with `personKey` — `sha256` of\n // the same normalized address does — must not be fetched twice: `getAll`\n // rejects duplicate references.\n .filter((id) => id !== key)\n .map((id) => members.doc(id))\n\n const snapshots = await input.listRef.firestore.getAll(\n canonicalRef,\n ...legacyRefs,\n )\n const existing = snapshots.find((snapshot) => snapshot.exists)\n const target = existing?.ref ?? canonicalRef\n\n if (\n existing &&\n readMarketingBasis(existing.data() as Record<string, unknown>, input.group)\n .basis === 'declined'\n ) {\n return { enrolled: false, refusal: 'declined' }\n }\n\n /*\n * ONE value from the two ways a caller may state a basis.\n *\n * `marketingConsent: true` is a capture surface saying \"a box was ticked\n * just now\", which is `contact-opt-in` recorded at this moment with nobody\n * asserting it. Normalizing here rather than branching at the write is what\n * keeps the consent fields to a single writer — two branches stamping\n * overlapping subsets of them is how a row comes to carry a basis with no\n * timestamp, or a timestamp with no basis.\n */\n const consent: ListMemberConsent | null =\n input.consent ??\n (input.marketingConsent\n ? { basis: 'contact-opt-in', atMs: Date.now() }\n : null)\n\n /*\n * The name the row will hold after this write — the caller's, else the one\n * it already carries — because the search tokens are the address's and\n * that name's, and a re-enrollment without a name keeps the stored one.\n */\n const name = input.name || (existing?.get('name') as string | undefined) || null\n await target.set(\n {\n email,\n ...(input.name ? { name: input.name } : {}),\n searchTokens: listMemberSearchTokens(email, name),\n source: input.source,\n /*\n * `via` is on every row, so the table's How filter can ask for it\n * (AGL-3321): the caller's, else `manual` on a row that has none — what\n * an absent `via` has always meant. A caller that says nothing never\n * moves a stored one.\n */\n ...(input.via\n ? { via: input.via }\n : existing?.get('via')\n ? {}\n : { via: 'manual' }),\n /*\n * Written only when the caller has a basis, and never unwritten.\n *\n * A merge that stamped `marketingConsent: false` on the omitted case\n * would erase a basis this person gave on an earlier enrollment, and a\n * withdrawn consent is not the same event as a re-enrollment that\n * happened to carry no checkbox. Withdrawal has its own path — the\n * unsubscribe link and the suppression list.\n *\n * `marketingConsentAtMs` moves with each fresh basis on purpose: the\n * question a consent record answers is when the person last said yes.\n *\n * The `marketingConsent` family rather than the `consent*` names\n * `docs/specs/email-overhaul.md` §3d sketched: `readMarketingBasis` is\n * the shipped reader and it reads these, so a second vocabulary here\n * would be a basis the send-time join cannot see. The two new fields\n * are attribution ON that field — they say why the person is mailable,\n * never whether — so nothing about the send decision moves into them.\n *\n * Written UNDER the enrolling GROUP, through the one writer helper. A\n * list lives on the org and every site in it can mail one, so a basis\n * written at the top of this row would enroll a person into one\n * client's newsletter and make them mailable by all of them. A group of\n * one — the default — is that client and nobody else.\n *\n * `marketingConsentByUid` is written on EVERY basis, `null` for a\n * pass-through. Leaving it absent would let a real opt-in that\n * supersedes an earlier attestation inherit the attesting account, and\n * a row reading \"this account vouched for them\" when the person ticked\n * a box themselves is a false attribution in the one direction a\n * compliance answer cannot afford.\n *\n * `marketingConsentReason` follows the same rule for the same reason:\n * always written, `''` for a pass-through, so a later real opt-in\n * cannot inherit the sentence an earlier import wrote about where the\n * merchant said the address came from.\n *\n * WHICH sites the basis covers is `membershipConsentFields`' to say,\n * and it never re-derives a grant from the group as it stands now.\n */\n ...(consent ? membershipConsentFields(input, consent) : {}),\n ...(existing\n ? {}\n : {\n addedAt: FieldValue.serverTimestamp(),\n [CAPTURED_BY_HOST_FIELD]: [input.group.hostId],\n }),\n },\n { merge: true },\n )\n\n return {\n enrolled: true,\n memberId: target.id,\n adopted: target.id !== key,\n created: !existing,\n }\n}\n\n/**\n * The consent fields one enrollment writes: the basis, its attribution, and\n * the sites it covers (AGL-3320).\n *\n * Three ways in, and the sites follow from which one it is:\n *\n * - a PASS-THROUGH with the person's {@link EnrollListMemberInput.grantEntries}\n * writes those sites and no others, each grant with its own moment and\n * the disclosure it was given under;\n * - a capture's checkbox pools over the group only when its surface sent\n * back the group's current disclosure key — `consentGroupForGrant`;\n * - everything else, an attestation among them, is the enrolling site's\n * alone.\n *\n * The attribution is the membership's own on every entry, the pass-through's\n * included, for the reason the write explains: an entry that kept an earlier\n * attestation's account under a person's own opt-in would be a false claim\n * about who vouched for them.\n */\nfunction membershipConsentFields(\n input: EnrollListMemberInput,\n consent: ListMemberConsent,\n): Record<string, unknown> {\n const attribution = {\n [MARKETING_CONSENT_BASIS_FIELD]: consent.basis,\n marketingConsentByUid: consent.byUid ?? null,\n marketingConsentReason: consent.reason ?? '',\n }\n const carried =\n consent.basis === 'contact-opt-in'\n ? Object.entries(input.grantEntries ?? {})\n : []\n if (carried.length) {\n return {\n [MARKETING_CONSENT_BY_HOST_FIELD]: Object.fromEntries(\n carried.map(([hostId, entry]) => [\n hostId,\n {\n ...entry,\n [MARKETING_CONSENT_FIELD]: true,\n // The grant's own moment; the pass-through's only when the\n // person's record never stamped one, as the basis itself falls\n // back to now.\n marketingConsentAtMs: entry['marketingConsentAtMs'] ?? consent.atMs,\n ...attribution,\n },\n ]),\n ),\n }\n }\n return marketingConsentFieldsForGroup(\n consentGroupForGrant(input.group, input.disclosedConsentGroup),\n consent.atMs,\n attribution,\n )\n}\n"],"names":["FieldValue","normalizeContactEmail","personKey","CAPTURED_BY_HOST_FIELD","MARKETING_CONSENT_BASIS_FIELD","MARKETING_CONSENT_BY_HOST_FIELD","MARKETING_CONSENT_FIELD","marketingConsentFieldsForGroup","readMarketingBasis","consentGroupForGrant","nameSearchTokens","createHash","createHmac","emailSearchTokens","listMemberSearchTokens","email","name","Set","legacyListMemberIds","normalizedEmail","update","digest","slice","listMemberDocIds","normalized","key","filter","id","enrollListMember","input","enrolled","refusal","members","listRef","collection","canonicalRef","doc","legacyRefs","map","snapshots","firestore","getAll","existing","find","snapshot","exists","target","ref","data","group","basis","consent","marketingConsent","atMs","Date","now","get","set","searchTokens","source","via","membershipConsentFields","addedAt","serverTimestamp","hostId","merge","memberId","adopted","created","attribution","marketingConsentByUid","byUid","marketingConsentReason","reason","carried","Object","entries","grantEntries","length","fromEntries","entry","marketingConsentAtMs","disclosedConsentGroup"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;CAoBC;AAGD,SAASA,UAAU,QAAQ,2BAA0B;AACrD,SACEC,qBAAqB,EACrBC,SAAS,EACTC,sBAAsB,EACtBC,6BAA6B,EAC7BC,+BAA+B,EAC/BC,uBAAuB,EACvBC,8BAA8B,EAC9BC,kBAAkB,QAEb,sBAAqB;AAC5B,SAASC,oBAAoB,QAAQ,wCAAuC;AAC5E,SAASC,gBAAgB,QAAQ,qCAAoC;AACrE,SAASC,UAAU,EAAEC,UAAU,QAAQ,cAAa;AACpD,SAASC,iBAAiB,QAAQ,yBAAqB;AAEvD;;;;;;;;;CASC,GACD,OAAO,SAASC,uBACdC,KAAgC,EAChCC,IAA+B;IAE/B,OAAO;WAAI,IAAIC,IAAI;eAAIJ,kBAAkBE;eAAWL,iBAAiBM;SAAM;KAAE;AAC/E;AAEA;;;;;;;;;;;CAWC,GACD,SAASE,oBAAoBC,eAAuB;IAClD,OAAO;QACLR,WAAW,UAAUS,MAAM,CAACD,iBAAiBE,MAAM,CAAC;QACpDT,WAAW,UAAU,qBAClBQ,MAAM,CAACD,iBACPE,MAAM,CAAC,OACPC,KAAK,CAAC,GAAG;KACb;AACH;AAEA;;;;;;;CAOC,GACD,OAAO,SAASC,iBAAiBR,KAAc;IAC7C,MAAMS,aAAavB,sBAAsBc;IACzC,IAAI,CAACS,YAAY,OAAO,EAAE;IAC1B,MAAMC,MAAMvB,UAAUsB;IACtB,IAAI,CAACC,KAAK,OAAO,EAAE;IACnB,OAAO;QAACA;WAAQP,oBAAoBM,YAAYE,MAAM,CAAC,CAACC,KAAOA,OAAOF;KAAK;AAC7E;AA2JA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkCC,GACD,OAAO,eAAeG,iBACpBC,KAA4B;cA0C1BA;IAxCF,MAAMd,QAAQd,sBAAsB4B,MAAMd,KAAK;IAC/C,IAAI,CAACA,OAAO,OAAO;QAAEe,UAAU;QAAOC,SAAS;IAAmB;IAClE,MAAMN,MAAMvB,UAAUa;IACtB,IAAI,CAACU,KAAK,OAAO;QAAEK,UAAU;QAAOC,SAAS;IAAmB;IAEhE,MAAMC,UAAUH,MAAMI,OAAO,CAACC,UAAU,CAAC;IACzC,MAAMC,eAAeH,QAAQI,GAAG,CAACX;IACjC,MAAMY,aAAanB,oBAAoBH,MACrC,2EAA2E;IAC3E,yEAAyE;IACzE,gCAAgC;KAC/BW,MAAM,CAAC,CAACC,KAAOA,OAAOF,KACtBa,GAAG,CAAC,CAACX,KAAOK,QAAQI,GAAG,CAACT;IAE3B,MAAMY,YAAY,MAAMV,MAAMI,OAAO,CAACO,SAAS,CAACC,MAAM,CACpDN,iBACGE;IAEL,MAAMK,WAAWH,UAAUI,IAAI,CAAC,CAACC,WAAaA,SAASC,MAAM;IAC7D,MAAMC,iBAASJ,4BAAAA,SAAUK,GAAG,mBAAIZ;IAEhC,IACEO,YACAlC,mBAAmBkC,SAASM,IAAI,IAA+BnB,MAAMoB,KAAK,EACvEC,KAAK,KAAK,YACb;QACA,OAAO;YAAEpB,UAAU;YAAOC,SAAS;QAAW;IAChD;IAEA;;;;;;;;;GASC,GACD,MAAMoB,WACJtB,iBAAAA,MAAMsB,OAAO,YAAbtB,iBACCA,MAAMuB,gBAAgB,GACnB;QAAEF,OAAO;QAAkBG,MAAMC,KAAKC,GAAG;IAAG,IAC5C;IAEN;;;;GAIC,GACD,MAAMvC,OAAOa,MAAMb,IAAI,KAAK0B,4BAAAA,SAAUc,GAAG,CAAC,YAAkC;IAC5E,MAAMV,OAAOW,GAAG,CACd;QACE1C;OACIc,MAAMb,IAAI,GAAG;QAAEA,MAAMa,MAAMb,IAAI;IAAC,IAAI,CAAC;QACzC0C,cAAc5C,uBAAuBC,OAAOC;QAC5C2C,QAAQ9B,MAAM8B,MAAM;OAOhB9B,MAAM+B,GAAG,GACT;QAAEA,KAAK/B,MAAM+B,GAAG;IAAC,IACjBlB,CAAAA,4BAAAA,SAAUc,GAAG,CAAC,UACZ,CAAC,IACD;QAAEI,KAAK;IAAS,GAyClBT,UAAUU,wBAAwBhC,OAAOsB,WAAW,CAAC,GACrDT,WACA,CAAC,IACD;QACEoB,SAAS9D,WAAW+D,eAAe;QACnC,CAAC5D,uBAAuB,EAAE;YAAC0B,MAAMoB,KAAK,CAACe,MAAM;SAAC;IAChD,IAEN;QAAEC,OAAO;IAAK;IAGhB,OAAO;QACLnC,UAAU;QACVoC,UAAUpB,OAAOnB,EAAE;QACnBwC,SAASrB,OAAOnB,EAAE,KAAKF;QACvB2C,SAAS,CAAC1B;IACZ;AACF;AAEA;;;;;;;;;;;;;;;;;;CAkBC,GACD,SAASmB,wBACPhC,KAA4B,EAC5BsB,OAA0B;QAIDA,gBACCA,iBAILtB;IAPrB,MAAMwC,cAAc;QAClB,CAACjE,8BAA8B,EAAE+C,QAAQD,KAAK;QAC9CoB,qBAAqB,GAAEnB,iBAAAA,QAAQoB,KAAK,YAAbpB,iBAAiB;QACxCqB,sBAAsB,GAAErB,kBAAAA,QAAQsB,MAAM,YAAdtB,kBAAkB;IAC5C;IACA,MAAMuB,UACJvB,QAAQD,KAAK,KAAK,mBACdyB,OAAOC,OAAO,EAAC/C,sBAAAA,MAAMgD,YAAY,YAAlBhD,sBAAsB,CAAC,KACtC,EAAE;IACR,IAAI6C,QAAQI,MAAM,EAAE;QAClB,OAAO;YACL,CAACzE,gCAAgC,EAAEsE,OAAOI,WAAW,CACnDL,QAAQpC,GAAG,CAAC,CAAC,CAAC0B,QAAQgB,MAAM;oBAQFA;uBARO;oBAC/BhB;oBACA,aACKgB;wBACH,CAAC1E,wBAAwB,EAAE;wBAC3B,2DAA2D;wBAC3D,+DAA+D;wBAC/D,eAAe;wBACf2E,oBAAoB,GAAED,8BAAAA,KAAK,CAAC,uBAAuB,YAA7BA,8BAAiC7B,QAAQE,IAAI;uBAChEgB;iBAEN;;QAEL;IACF;IACA,OAAO9D,+BACLE,qBAAqBoB,MAAMoB,KAAK,EAAEpB,MAAMqD,qBAAqB,GAC7D/B,QAAQE,IAAI,EACZgB;AAEJ"}
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /** The part of a list query plan the query is built from. */
18
+ export interface AdminListQueryPlan {
19
+ filters: ReadonlyArray<{
20
+ path: string;
21
+ op: FirebaseFirestore.WhereFilterOp;
22
+ value: unknown;
23
+ }>;
24
+ orderBy: {
25
+ path: string;
26
+ direction: 'asc' | 'desc';
27
+ };
28
+ }
29
+ /**
30
+ * Every predicate the plan holds, then its one order.
31
+ *
32
+ * The caller pages it with its own cursor (`startAfter` a document in the
33
+ * plan's order) and matches nothing afterwards: what the plan refused is not
34
+ * applied, and the route says so in its response (`plan.refused`).
35
+ */
36
+ export declare function applyListQuery(ref: FirebaseFirestore.Query, plan: AdminListQueryPlan): FirebaseFirestore.Query;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { FieldPath, Timestamp } from "firebase-admin/firestore";
17
+ /*
18
+ * THE ADMIN TWIN OF THE LIST QUERY PLAN (AGL-3321).
19
+ *
20
+ * `planListQuery` (`@aglyn/shared-ui-jsx/const/list-query-plan`) composes a
21
+ * list's clauses and search into one SDK-free plan; this puts that plan on
22
+ * an Admin query, for a route that reads a list on the server — a console
23
+ * route or a plugin's API handler alike, which is why it lives here and not
24
+ * in the console. The web twin is `listQueryConstraints` in the instance
25
+ * library.
26
+ *
27
+ * The plan's shape is restated structurally rather than imported, so this
28
+ * data library stays free of the UI library that plans it: any object with
29
+ * these fields is a plan, and `ListQueryPlan` is one.
30
+ */ /** The document id, as the plan spells it. */ const ID_PATH = '__name__';
31
+ const planValue = (value)=>value instanceof Date ? Timestamp.fromDate(value) : Array.isArray(value) ? [
32
+ ...value
33
+ ] : value;
34
+ /**
35
+ * Every predicate the plan holds, then its one order.
36
+ *
37
+ * The caller pages it with its own cursor (`startAfter` a document in the
38
+ * plan's order) and matches nothing afterwards: what the plan refused is not
39
+ * applied, and the route says so in its response (`plan.refused`).
40
+ */ export function applyListQuery(ref, plan) {
41
+ const path = (value)=>value === ID_PATH ? FieldPath.documentId() : value;
42
+ let query = ref;
43
+ for (const filter of plan.filters){
44
+ query = query.where(path(filter.path), filter.op, planValue(filter.value));
45
+ }
46
+ return query.orderBy(path(plan.orderBy.path), plan.orderBy.direction);
47
+ }
48
+
49
+ //# sourceMappingURL=list-query.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/list-query.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { FieldPath, Timestamp } from 'firebase-admin/firestore'\n\n/*\n * THE ADMIN TWIN OF THE LIST QUERY PLAN (AGL-3321).\n *\n * `planListQuery` (`@aglyn/shared-ui-jsx/const/list-query-plan`) composes a\n * list's clauses and search into one SDK-free plan; this puts that plan on\n * an Admin query, for a route that reads a list on the server — a console\n * route or a plugin's API handler alike, which is why it lives here and not\n * in the console. The web twin is `listQueryConstraints` in the instance\n * library.\n *\n * The plan's shape is restated structurally rather than imported, so this\n * data library stays free of the UI library that plans it: any object with\n * these fields is a plan, and `ListQueryPlan` is one.\n */\n\n/** The document id, as the plan spells it. */\nconst ID_PATH = '__name__'\n\n/** The part of a list query plan the query is built from. */\nexport interface AdminListQueryPlan {\n filters: ReadonlyArray<{\n path: string\n op: FirebaseFirestore.WhereFilterOp\n value: unknown\n }>\n orderBy: { path: string; direction: 'asc' | 'desc' }\n}\n\nconst planValue = (value: unknown): unknown =>\n value instanceof Date ? Timestamp.fromDate(value) : Array.isArray(value) ? [...value] : value\n\n/**\n * Every predicate the plan holds, then its one order.\n *\n * The caller pages it with its own cursor (`startAfter` a document in the\n * plan's order) and matches nothing afterwards: what the plan refused is not\n * applied, and the route says so in its response (`plan.refused`).\n */\nexport function applyListQuery(\n ref: FirebaseFirestore.Query,\n plan: AdminListQueryPlan,\n): FirebaseFirestore.Query {\n const path = (value: string) => (value === ID_PATH ? FieldPath.documentId() : value)\n let query = ref\n for (const filter of plan.filters) {\n query = query.where(path(filter.path), filter.op, planValue(filter.value))\n }\n return query.orderBy(path(plan.orderBy.path), plan.orderBy.direction)\n}\n"],"names":["FieldPath","Timestamp","ID_PATH","planValue","value","Date","fromDate","Array","isArray","applyListQuery","ref","plan","path","documentId","query","filter","filters","where","op","orderBy","direction"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,SAAS,EAAEC,SAAS,QAAQ,2BAA0B;AAE/D;;;;;;;;;;;;;CAaC,GAED,4CAA4C,GAC5C,MAAMC,UAAU;AAYhB,MAAMC,YAAY,CAACC,QACjBA,iBAAiBC,OAAOJ,UAAUK,QAAQ,CAACF,SAASG,MAAMC,OAAO,CAACJ,SAAS;WAAIA;KAAM,GAAGA;AAE1F;;;;;;CAMC,GACD,OAAO,SAASK,eACdC,GAA4B,EAC5BC,IAAwB;IAExB,MAAMC,OAAO,CAACR,QAAmBA,UAAUF,UAAUF,UAAUa,UAAU,KAAKT;IAC9E,IAAIU,QAAQJ;IACZ,KAAK,MAAMK,UAAUJ,KAAKK,OAAO,CAAE;QACjCF,QAAQA,MAAMG,KAAK,CAACL,KAAKG,OAAOH,IAAI,GAAGG,OAAOG,EAAE,EAAEf,UAAUY,OAAOX,KAAK;IAC1E;IACA,OAAOU,MAAMK,OAAO,CAACP,KAAKD,KAAKQ,OAAO,CAACP,IAAI,GAAGD,KAAKQ,OAAO,CAACC,SAAS;AACtE"}
@@ -16,6 +16,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
16
16
  * limitations under the License.
17
17
  */ import { FieldValue, Timestamp } from "firebase-admin/firestore";
18
18
  import { checkQuota } from "@aglyn/aglyn/server";
19
+ import { mediaFilterKeys } from "@aglyn/aglyn/app-utils/media-metadata";
19
20
  import { removeMediaDeliveryCopies, withoutMediaDeliveryCopies } from "./media-delivery.js";
20
21
  /**
21
22
  * What a DAM delete has to leave behind for an undo to be possible at all
@@ -394,13 +395,18 @@ import { removeMediaDeliveryCopies, withoutMediaDeliveryCopies } from "./media-d
394
395
  });
395
396
  }
396
397
  const restored = await scopeRef.firestore.runTransaction(async (transaction)=>{
397
- var _record_sizeBytes;
398
+ var _media_folderId, _record_sizeBytes;
398
399
  const fresh = await transaction.get(tombstoneRef);
399
400
  if (!fresh.exists) return false;
400
401
  const record = fresh.data();
401
402
  // Without its delivery copy record: the delete removed those copies,
402
403
  // and a record of them would name objects that are gone (AGL-2824).
403
- transaction.set(mediaRef, withoutMediaDeliveryCopies(record.media));
404
+ // With the library's filter keys re-derived, so a file deleted before
405
+ // they existed comes back findable by them (AGL-3327).
406
+ const media = withoutMediaDeliveryCopies(record.media);
407
+ transaction.set(mediaRef, _extends({}, media, mediaFilterKeys(media), {
408
+ folderId: (_media_folderId = media.folderId) != null ? _media_folderId : null
409
+ }));
404
410
  transaction.delete(tombstoneRef);
405
411
  transaction.set(counterRef, {
406
412
  bytes: FieldValue.increment(Number((_record_sizeBytes = record.sizeBytes) != null ? _record_sizeBytes : 0)),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/media-tombstone.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { FieldValue, Timestamp } from 'firebase-admin/firestore'\nimport { checkQuota } from '@aglyn/aglyn/server'\nimport {\n removeMediaDeliveryCopies,\n withoutMediaDeliveryCopies,\n} from './media-delivery'\n\n/**\n * What a DAM delete has to leave behind for an undo to be possible at all\n * (AGL-1467).\n *\n * ## The gap this closes\n *\n * The bucket has soft-delete on with a **seven-day** retention, so the bytes\n * of a deleted asset really are still there. That was only ever half of an\n * undo, and AGL-1461 shipped honest copy rather than a button because of the\n * other half: `/api/media/upload`'s DELETE branch hard-deleted the Firestore\n * document. After it ran, nothing in the product knew the asset's id, file\n * name, folder, tags, alt text, description, custom metadata, `visibleTo`\n * scope tokens, `cdnPath` or `variants` — and `File.restore()` needs a\n * **generation number** that nobody had captured. Surviving bytes with no\n * address are not a recovery story. On 2026-08-13 two files deleted in error\n * came back only because an unrelated analysis document happened to have\n * recorded their ids; a customer has no such document.\n *\n * A tombstone is the three facts that make the bytes addressable again:\n *\n * 1. the media document verbatim, as it was at the moment of the delete;\n * 2. the **generation** of the storage object and of every CDN variant,\n * read BEFORE the delete — afterwards there is no live object to ask;\n * 3. `sizeBytes`, so the counter decrement can be reversed by exactly the\n * amount it moved rather than by a recomputation that can drift.\n *\n * ## Where it lives, and why that is the whole erasure answer\n *\n * `{hosts|orgs}/{scopeId}/mediaTombstones/{mediaId}` — a subcollection of the\n * scope that owned the asset, never a top-level collection keyed by media id.\n *\n * This is the load-bearing choice. `eraseHost` ends in\n * `recursiveDelete(hostRef)` and `eraseOrg` ends in `recursiveDelete(orgRef)`,\n * and a path-scoped cascade takes every subcollection under the path with it.\n * Put the tombstones there and an erasure removes them with no new code — put\n * them in a top-level `mediaTombstones/{id}` with an `orgId` field and they\n * become exactly the shape AGL-1444 and AGL-1448 had to go back and wire by\n * hand: a collection the cascade is structurally blind to, holding a verbatim\n * copy of customer content, surviving the request that promised it was gone.\n * A tombstone is customer data. It has to die with the customer.\n *\n * (The property is asserted rather than assumed —\n * `erase-media-tombstones.emulator.spec.ts` erases a host and an org that each\n * hold one and looks for the survivor.)\n *\n * ## Retention: the same seven days as the bytes, and not a day more\n *\n * `expiresAt` is stamped at `MEDIA_TOMBSTONE_RETENTION_MS` past the delete,\n * which is the bucket's soft-delete window exactly. Both other numbers are\n * wrong for the same reason from opposite directions: a shorter tombstone\n * strands recoverable bytes with no address, which is today's defect made\n * smaller, and a longer one can only ever produce a failed restore while\n * holding a copy of a customer's asset for no reachable purpose. AGL-1443 is\n * open on precisely that — a durable copy of customer data whose retention\n * nobody chose — and this must not become a second instance of it.\n *\n * The field is enforced two ways, because neither alone is enough. A Firestore\n * TTL policy on `mediaTombstones.expiresAt` reaps them (see\n * `docs/FIRESTORE_MANUAL_CONFIG.md`), and TTL deletion is explicitly not\n * prompt — so `restoreMediaFromTombstone` treats an expired tombstone as\n * absent and deletes it on sight rather than trusting the sweeper.\n */\n\n/** The subcollection, under the scope that owned the asset. */\nexport const MEDIA_TOMBSTONE_COLLECTION = 'mediaTombstones'\n\n/**\n * How long a tombstone lives: the bucket's soft-delete retention, exactly.\n *\n * Changing this without changing the bucket policy breaks the invariant the\n * whole module rests on — see the header.\n */\nexport const MEDIA_TOMBSTONE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000\n\n/** Days, for the copy that has to name the window to a person. */\nexport const MEDIA_TOMBSTONE_RETENTION_DAYS = Math.round(\n MEDIA_TOMBSTONE_RETENTION_MS / (24 * 60 * 60 * 1000),\n)\n\n/**\n * One deleted object and the generation that addresses it.\n *\n * `generation` is nullable on purpose: reading it is a Storage round-trip that\n * can fail, and an asset whose object was ALREADY missing (replaced, swept,\n * never written) must still produce a tombstone — the document half of the\n * restore is worth having on its own. A null generation means \"nothing to\n * restore here\", not \"restore failed\".\n */\nexport interface MediaObjectGeneration {\n path: string\n generation: string | null\n}\n\n/** The stored tombstone document. */\nexport interface MediaTombstoneDoc {\n /** The media document verbatim, exactly as the delete found it. */\n media: Record<string, unknown>\n /** Primary object first, then every CDN variant. */\n objects: MediaObjectGeneration[]\n /** The counter decrement to reverse, taken from the document. */\n sizeBytes: number\n fileName: string\n deletedBy: string\n deletedAt: Timestamp | number\n /**\n * Hard bound — see the header. Written as a **Timestamp**, which the TTL\n * policy requires (a number field cannot carry one, which is why\n * `bookings.expiresAtMs` is documented as not a TTL target). Read through\n * `isMediaTombstoneExpired`, which takes either form so a hand-written\n * fixture and the real write are both legible.\n */\n expiresAt: Timestamp | number\n}\n\n/**\n * The slice of `File` this module needs; the real one satisfies it.\n *\n * `restore` takes a NUMBER because the SDK's `RestoreOptions` does, while the\n * tombstone stores the generation as a string — a GCS generation is a\n * microsecond timestamp (~1.7e15), comfortably inside a double, but a string\n * is what survives JSON, a Firestore round-trip and a log line without anyone\n * having to reason about that. The conversion happens once, at the call.\n */\nexport interface TombstoneStorageFile {\n getMetadata(): Promise<any>\n delete(): Promise<any>\n exists(): Promise<any>\n restore(options: { generation: number }): Promise<any>\n}\n\n/** The slice of `Bucket` this module needs. */\nexport interface TombstoneStorageBucket {\n file(path: string): TombstoneStorageFile\n}\n\n/** When a tombstone written now stops being useful. */\nexport function mediaTombstoneExpiry(deletedAtMs: number): number {\n return deletedAtMs + MEDIA_TOMBSTONE_RETENTION_MS\n}\n\n/** Milliseconds out of a Firestore Timestamp or a plain number. */\nfunction toMillis(value: unknown): number {\n const timestamp = value as { toMillis?: () => number }\n if (typeof timestamp?.toMillis === 'function') return timestamp.toMillis()\n return Number(value ?? 0)\n}\n\n/**\n * Whether the bytes this tombstone addresses are past the bucket's window.\n *\n * Missing or unreadable reads as EXPIRED, which is the safe direction: the\n * failure mode of a false positive is a refusal with a clear message, and the\n * failure mode of a false negative is a restore that half-succeeds against\n * bytes the bucket has already reaped.\n */\nexport function isMediaTombstoneExpired(\n tombstone: Pick<MediaTombstoneDoc, 'expiresAt'>,\n nowMs: number,\n): boolean {\n return !(toMillis(tombstone.expiresAt) > nowMs)\n}\n\n/**\n * Read the generation of each object, best-effort (AGL-1467).\n *\n * Must run BEFORE the delete. Once the object is soft-deleted there is no live\n * generation to read, and the number is the only handle `restore()` takes —\n * this is the single fact whose absence made undo impossible rather than\n * merely unbuilt.\n *\n * A failure here degrades the tombstone to its document half rather than\n * failing the delete: an author who asked for a file to go must not be told\n * \"no\" because a metadata read timed out.\n */\nexport async function captureObjectGenerations(\n bucket: TombstoneStorageBucket,\n paths: readonly string[],\n): Promise<MediaObjectGeneration[]> {\n return Promise.all(\n paths.map(async (path) => {\n try {\n const response = await bucket.file(path).getMetadata()\n const metadata = Array.isArray(response) ? response[0] : response\n const generation = metadata?.generation\n return {\n path,\n generation:\n generation === undefined || generation === null\n ? null\n : String(generation),\n }\n } catch {\n return { path, generation: null }\n }\n }),\n )\n}\n\n/**\n * Removes the delivery provider's copies of a deleted asset (AGL-2824).\n *\n * Unlike the bucket's objects they are not kept for a restore — a restore\n * copies the video again — and no tombstone records them. The library is\n * read off the scope reference (`hosts/{id}` or `orgs/{id}`). Nothing runs\n * when no provider is configured to store, and a failure is logged rather\n * than answered, like the object deletes beside it.\n */\nasync function removeDeliveryCopies(\n scopeRef: FirebaseFirestore.DocumentReference,\n mediaId: string,\n): Promise<void> {\n const collection = scopeRef.parent?.id\n if (collection !== 'hosts' && collection !== 'orgs') return\n await removeMediaDeliveryCopies({\n asset: { collection, scopeId: scopeRef.id, mediaId },\n })\n}\n\nexport interface DeleteMediaResult {\n /** False when there was no document to delete (already gone). */\n deleted: boolean\n /** Present when a tombstone was written — the id is the media id. */\n tombstone?: { mediaId: string; expiresAt: number; fileName: string }\n}\n\n/**\n * Delete a media document and leave a tombstone behind, in one commit.\n *\n * **The invariant: the media document is never destroyed except in the same\n * transaction that writes its tombstone and moves the counter.** All three\n * belong to one commit, so there is no interleaving in which the document is\n * gone and the record of it is not — which is the state the product was\n * permanently in before this issue.\n *\n * The object delete happens AFTER that commit, deliberately, and the ordering\n * is a choice between two partial failures:\n *\n * - object first: a failed commit leaves a live document pointing at bytes\n * that are already soft-deleted — a broken asset in the library that\n * nothing detects;\n * - commit first: a failed object delete leaves a tombstone whose object is\n * still live, which `restoreMediaFromTombstone` handles by skipping the\n * restore for an object that is already there.\n *\n * The second is recoverable by the code in this file. The first is not\n * recoverable by anything.\n */\nexport async function deleteMediaWithTombstone(options: {\n scopeRef: FirebaseFirestore.DocumentReference\n bucket: TombstoneStorageBucket\n mediaId: string\n /** Path of the primary object, resolved by the caller (legacy fallback). */\n objectPath: string\n /** Who asked — an erasure record with no actor is half a record. */\n uid: string\n nowMs?: number\n}): Promise<DeleteMediaResult> {\n const { scopeRef, bucket, mediaId, objectPath, uid } = options\n const nowMs = options.nowMs ?? Date.now()\n const mediaRef = scopeRef.collection('media').doc(mediaId)\n const snapshot = await mediaRef.get()\n if (!snapshot.exists) {\n // No document, so nothing to record and nothing to restore to. The object\n // sweep below still runs: an orphan object is exactly what this branch is\n // for, and it predates the tombstone.\n await bucket.file(objectPath).delete().catch(() => undefined)\n await removeDeliveryCopies(scopeRef, mediaId)\n return { deleted: false }\n }\n\n const variantWidths: number[] = snapshot.get('variants') ?? []\n const variantPaths = variantWidths.map(\n (width) => `${objectPath}__w${width}.webp`,\n )\n // BEFORE the delete — the whole point. See `captureObjectGenerations`.\n const objects = await captureObjectGenerations(bucket, [\n objectPath,\n ...variantPaths,\n ])\n\n const sizeBytes = Number(snapshot.get('sizeBytes') ?? 0)\n const fileName = String(snapshot.get('fileName') ?? mediaId)\n const expiresAt = mediaTombstoneExpiry(nowMs)\n const tombstoneRef = scopeRef\n .collection(MEDIA_TOMBSTONE_COLLECTION)\n .doc(mediaId)\n const counterRef = scopeRef.collection('counters').doc('media')\n\n const committed = await scopeRef.firestore.runTransaction(\n async (transaction) => {\n const fresh = await transaction.get(mediaRef)\n // Someone else deleted it between the read above and here. Their commit\n // wrote the tombstone; ours must not write a second one over it.\n if (!fresh.exists) return false\n const data = (fresh.data() ?? {}) as Record<string, unknown>\n const tombstone: MediaTombstoneDoc = {\n media: data,\n objects,\n sizeBytes: Number(data['sizeBytes'] ?? sizeBytes) || 0,\n fileName: String(data['fileName'] ?? fileName),\n deletedBy: uid,\n deletedAt: Timestamp.fromMillis(nowMs),\n // Timestamp, not a number: the TTL policy on\n // `mediaTombstones.expiresAt` cannot key on anything else.\n expiresAt: Timestamp.fromMillis(expiresAt),\n }\n transaction.set(tombstoneRef, tombstone)\n transaction.delete(mediaRef)\n transaction.set(\n counterRef,\n {\n bytes: FieldValue.increment(-tombstone.sizeBytes),\n count: FieldValue.increment(-1),\n },\n { merge: true },\n )\n return true\n },\n )\n\n // Object and variants last, best-effort, exactly as before — the bucket\n // keeps them for the retention window either way.\n await bucket.file(objectPath).delete().catch(() => undefined)\n await Promise.all(\n variantPaths.map((path) =>\n bucket.file(path).delete().catch(() => undefined),\n ),\n )\n await removeDeliveryCopies(scopeRef, mediaId)\n\n if (!committed) return { deleted: false }\n return { deleted: true, tombstone: { mediaId, expiresAt, fileName } }\n}\n\nexport interface RestoreMediaResult {\n ok: boolean\n /** HTTP status the route should answer with. */\n status: number\n /** A real sentence for a person — never a bare code. */\n message: string\n fileName?: string\n}\n\n/**\n * Why a restore cannot proceed, decided without touching Storage (AGL-1467).\n *\n * Pure, because these are the answers a person actually sees and the two\n * interesting ones are cheap to get wrong: an expired tombstone must fail\n * CLEANLY rather than half-succeed against bytes the bucket has already\n * reaped, and a restore that would breach the plan must say so rather than\n * push the counter past the limit in silence.\n *\n * ## The quota call\n *\n * A restore re-adds real bytes, so it is gated exactly like an upload —\n * `storagePerHostMb`, same off-by-one — and REFUSED when it would breach the\n * plan. Waiving it would let delete-then-restore launder an over-quota\n * library, which is the AGL-1471/AGL-1279 shape (a limit evaluated at a moment\n * the caller controls). Refusing is safe precisely because the tombstone\n * outlives the refusal: the asset is still restorable for the rest of the\n * window once space is freed, which is what makes \"no, and here is why\"\n * an honest answer rather than a loss.\n *\n * Note what is NOT metered: the soft-deleted bytes themselves. They sit in the\n * bucket for seven days at Aglyn's cost, and counting them would mean deleting\n * a file did not free space until a week later — which breaks the one action\n * an author takes when they hit the wall. The window is the price of offering\n * undo, not an allocation to bill back.\n */\nexport function mediaRestoreRefusal(options: {\n tombstone: Pick<MediaTombstoneDoc, 'expiresAt' | 'sizeBytes' | 'fileName'>\n nowMs: number\n /** Counter bytes as they stand now, with the asset already removed. */\n usedBytes: number\n billing: Record<string, unknown> | undefined\n}): { status: number; message: string } | null {\n const { tombstone, nowMs, usedBytes, billing } = options\n if (isMediaTombstoneExpired(tombstone, nowMs)) {\n return {\n status: 410,\n message:\n `\"${tombstone.fileName}\" was deleted more than ` +\n `${MEDIA_TOMBSTONE_RETENTION_DAYS} days ago, so its file is no ` +\n 'longer recoverable.',\n }\n }\n const usedMb = (usedBytes + Number(tombstone.sizeBytes ?? 0)) / (1024 * 1024)\n const quota = checkQuota(\n billing as never,\n 'storagePerHostMb',\n Math.ceil(usedMb) - 1,\n )\n if (!quota.allowed) {\n return {\n status: 403,\n message:\n `Restoring \"${tombstone.fileName}\" would put this library over its ` +\n `storage limit (${quota.limit} MB). Free up space and try again — ` +\n 'the file stays restorable until the window closes.',\n }\n }\n return null\n}\n\n/**\n * Put a deleted asset back: object, variants, document and counters.\n *\n * Order is the same argument as the delete, run backwards. Bytes first, then\n * the commit — so the only partial state this can leave is an object restored\n * with no document, which the tombstone (still present, because the commit is\n * what consumes it) makes a retry away from finished. The reverse order would\n * publish a document whose bytes may never arrive.\n *\n * The commit itself carries the document write, the tombstone delete and both\n * counter increments together, and it re-reads the tombstone inside the\n * transaction. That re-read is what makes a double-click harmless: the second\n * transaction finds no tombstone, commits nothing, and the caller is told the\n * file is already back rather than being charged for it twice.\n */\nexport async function restoreMediaFromTombstone(options: {\n scopeRef: FirebaseFirestore.DocumentReference\n bucket: TombstoneStorageBucket\n mediaId: string\n billing: Record<string, unknown> | undefined\n nowMs?: number\n}): Promise<RestoreMediaResult> {\n const { scopeRef, bucket, mediaId, billing } = options\n const nowMs = options.nowMs ?? Date.now()\n const mediaRef = scopeRef.collection('media').doc(mediaId)\n const tombstoneRef = scopeRef\n .collection(MEDIA_TOMBSTONE_COLLECTION)\n .doc(mediaId)\n const counterRef = scopeRef.collection('counters').doc('media')\n\n const tombstoneSnapshot = await tombstoneRef.get()\n if (!tombstoneSnapshot.exists) {\n // Either it was never written, or the window closed and the TTL swept it.\n // Both are the same fact to the caller, and neither is an error state the\n // product can act on.\n const media = await mediaRef.get()\n if (media.exists) {\n return {\n ok: true,\n status: 200,\n message: 'That file is already back in the library.',\n fileName: String(media.get('fileName') ?? mediaId),\n }\n }\n return {\n ok: false,\n status: 404,\n message: 'That file can no longer be restored.',\n }\n }\n\n const tombstone = tombstoneSnapshot.data() as MediaTombstoneDoc\n const counter = await counterRef.get()\n const usedBytes = Number(counter.get('bytes') ?? 0)\n const refusal = mediaRestoreRefusal({\n tombstone,\n nowMs,\n usedBytes,\n billing,\n })\n if (refusal) {\n // An expired tombstone is dead weight — a copy of customer data whose only\n // possible future is this refusal. Reap it here rather than waiting on a\n // TTL sweep that is documented as unprompt.\n if (refusal.status === 410) {\n await tombstoneRef.delete().catch(() => undefined)\n }\n return { ok: false, ...refusal, fileName: tombstone.fileName }\n }\n\n const objects = Array.isArray(tombstone.objects) ? tombstone.objects : []\n const [primary, ...variants] = objects\n const restoreObject = async (\n object: MediaObjectGeneration,\n ): Promise<boolean> => {\n if (!object?.generation) return false\n const file = bucket.file(object.path)\n // Already live — the delete's object sweep is best-effort, so this is a\n // real branch, not a defensive one. `restore()` on a live generation is\n // not a no-op, so the check has to come first.\n const existing = await file.exists().catch(() => [false])\n if (Array.isArray(existing) ? existing[0] : existing) return true\n await file.restore({ generation: Number(object.generation) })\n return true\n }\n\n if (primary) {\n try {\n await restoreObject(primary)\n } catch (error) {\n console.error(\n `restoreMediaFromTombstone: object restore failed for ${mediaId}`,\n error,\n )\n return {\n ok: false,\n status: 502,\n message:\n `\"${tombstone.fileName}\" could not be restored — its file could ` +\n 'not be recovered from storage. Nothing was changed.',\n fileName: tombstone.fileName,\n }\n }\n }\n // Variants are derived artifacts the platform can regenerate, and the upload\n // path already refuses to fail an asset for them. Same call here.\n for (const variant of variants) {\n await restoreObject(variant).catch((error) => {\n console.error(\n `restoreMediaFromTombstone: variant ${variant?.path} failed`,\n error,\n )\n return false\n })\n }\n\n const restored = await scopeRef.firestore.runTransaction(\n async (transaction) => {\n const fresh = await transaction.get(tombstoneRef)\n if (!fresh.exists) return false\n const record = fresh.data() as MediaTombstoneDoc\n // Without its delivery copy record: the delete removed those copies,\n // and a record of them would name objects that are gone (AGL-2824).\n transaction.set(mediaRef, withoutMediaDeliveryCopies(record.media))\n transaction.delete(tombstoneRef)\n transaction.set(\n counterRef,\n {\n bytes: FieldValue.increment(Number(record.sizeBytes ?? 0)),\n count: FieldValue.increment(1),\n },\n { merge: true },\n )\n return true\n },\n )\n\n return {\n ok: true,\n status: 200,\n message: restored\n ? `Restored \"${tombstone.fileName}\".`\n : 'That file is already back in the library.',\n fileName: tombstone.fileName,\n }\n}\n"],"names":["FieldValue","Timestamp","checkQuota","removeMediaDeliveryCopies","withoutMediaDeliveryCopies","MEDIA_TOMBSTONE_COLLECTION","MEDIA_TOMBSTONE_RETENTION_MS","MEDIA_TOMBSTONE_RETENTION_DAYS","Math","round","mediaTombstoneExpiry","deletedAtMs","toMillis","value","timestamp","Number","isMediaTombstoneExpired","tombstone","nowMs","expiresAt","captureObjectGenerations","bucket","paths","Promise","all","map","path","response","file","getMetadata","metadata","Array","isArray","generation","undefined","String","removeDeliveryCopies","scopeRef","mediaId","collection","parent","id","asset","scopeId","deleteMediaWithTombstone","options","snapshot","objectPath","uid","Date","now","mediaRef","doc","get","exists","delete","catch","deleted","variantWidths","variantPaths","width","objects","sizeBytes","fileName","tombstoneRef","counterRef","committed","firestore","runTransaction","transaction","fresh","data","media","deletedBy","deletedAt","fromMillis","set","bytes","increment","count","merge","mediaRestoreRefusal","usedBytes","billing","status","message","usedMb","quota","ceil","allowed","limit","restoreMediaFromTombstone","counter","tombstoneSnapshot","ok","refusal","primary","variants","restoreObject","object","existing","restore","error","console","variant","restored","record"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/media-tombstone.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { FieldValue, Timestamp } from 'firebase-admin/firestore'\nimport { checkQuota } from '@aglyn/aglyn/server'\nimport { mediaFilterKeys } from '@aglyn/aglyn/app-utils/media-metadata'\nimport {\n removeMediaDeliveryCopies,\n withoutMediaDeliveryCopies,\n} from './media-delivery'\n\n/**\n * What a DAM delete has to leave behind for an undo to be possible at all\n * (AGL-1467).\n *\n * ## The gap this closes\n *\n * The bucket has soft-delete on with a **seven-day** retention, so the bytes\n * of a deleted asset really are still there. That was only ever half of an\n * undo, and AGL-1461 shipped honest copy rather than a button because of the\n * other half: `/api/media/upload`'s DELETE branch hard-deleted the Firestore\n * document. After it ran, nothing in the product knew the asset's id, file\n * name, folder, tags, alt text, description, custom metadata, `visibleTo`\n * scope tokens, `cdnPath` or `variants` — and `File.restore()` needs a\n * **generation number** that nobody had captured. Surviving bytes with no\n * address are not a recovery story. On 2026-08-13 two files deleted in error\n * came back only because an unrelated analysis document happened to have\n * recorded their ids; a customer has no such document.\n *\n * A tombstone is the three facts that make the bytes addressable again:\n *\n * 1. the media document verbatim, as it was at the moment of the delete;\n * 2. the **generation** of the storage object and of every CDN variant,\n * read BEFORE the delete — afterwards there is no live object to ask;\n * 3. `sizeBytes`, so the counter decrement can be reversed by exactly the\n * amount it moved rather than by a recomputation that can drift.\n *\n * ## Where it lives, and why that is the whole erasure answer\n *\n * `{hosts|orgs}/{scopeId}/mediaTombstones/{mediaId}` — a subcollection of the\n * scope that owned the asset, never a top-level collection keyed by media id.\n *\n * This is the load-bearing choice. `eraseHost` ends in\n * `recursiveDelete(hostRef)` and `eraseOrg` ends in `recursiveDelete(orgRef)`,\n * and a path-scoped cascade takes every subcollection under the path with it.\n * Put the tombstones there and an erasure removes them with no new code — put\n * them in a top-level `mediaTombstones/{id}` with an `orgId` field and they\n * become exactly the shape AGL-1444 and AGL-1448 had to go back and wire by\n * hand: a collection the cascade is structurally blind to, holding a verbatim\n * copy of customer content, surviving the request that promised it was gone.\n * A tombstone is customer data. It has to die with the customer.\n *\n * (The property is asserted rather than assumed —\n * `erase-media-tombstones.emulator.spec.ts` erases a host and an org that each\n * hold one and looks for the survivor.)\n *\n * ## Retention: the same seven days as the bytes, and not a day more\n *\n * `expiresAt` is stamped at `MEDIA_TOMBSTONE_RETENTION_MS` past the delete,\n * which is the bucket's soft-delete window exactly. Both other numbers are\n * wrong for the same reason from opposite directions: a shorter tombstone\n * strands recoverable bytes with no address, which is today's defect made\n * smaller, and a longer one can only ever produce a failed restore while\n * holding a copy of a customer's asset for no reachable purpose. AGL-1443 is\n * open on precisely that — a durable copy of customer data whose retention\n * nobody chose — and this must not become a second instance of it.\n *\n * The field is enforced two ways, because neither alone is enough. A Firestore\n * TTL policy on `mediaTombstones.expiresAt` reaps them (see\n * `docs/FIRESTORE_MANUAL_CONFIG.md`), and TTL deletion is explicitly not\n * prompt — so `restoreMediaFromTombstone` treats an expired tombstone as\n * absent and deletes it on sight rather than trusting the sweeper.\n */\n\n/** The subcollection, under the scope that owned the asset. */\nexport const MEDIA_TOMBSTONE_COLLECTION = 'mediaTombstones'\n\n/**\n * How long a tombstone lives: the bucket's soft-delete retention, exactly.\n *\n * Changing this without changing the bucket policy breaks the invariant the\n * whole module rests on — see the header.\n */\nexport const MEDIA_TOMBSTONE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000\n\n/** Days, for the copy that has to name the window to a person. */\nexport const MEDIA_TOMBSTONE_RETENTION_DAYS = Math.round(\n MEDIA_TOMBSTONE_RETENTION_MS / (24 * 60 * 60 * 1000),\n)\n\n/**\n * One deleted object and the generation that addresses it.\n *\n * `generation` is nullable on purpose: reading it is a Storage round-trip that\n * can fail, and an asset whose object was ALREADY missing (replaced, swept,\n * never written) must still produce a tombstone — the document half of the\n * restore is worth having on its own. A null generation means \"nothing to\n * restore here\", not \"restore failed\".\n */\nexport interface MediaObjectGeneration {\n path: string\n generation: string | null\n}\n\n/** The stored tombstone document. */\nexport interface MediaTombstoneDoc {\n /** The media document verbatim, exactly as the delete found it. */\n media: Record<string, unknown>\n /** Primary object first, then every CDN variant. */\n objects: MediaObjectGeneration[]\n /** The counter decrement to reverse, taken from the document. */\n sizeBytes: number\n fileName: string\n deletedBy: string\n deletedAt: Timestamp | number\n /**\n * Hard bound — see the header. Written as a **Timestamp**, which the TTL\n * policy requires (a number field cannot carry one, which is why\n * `bookings.expiresAtMs` is documented as not a TTL target). Read through\n * `isMediaTombstoneExpired`, which takes either form so a hand-written\n * fixture and the real write are both legible.\n */\n expiresAt: Timestamp | number\n}\n\n/**\n * The slice of `File` this module needs; the real one satisfies it.\n *\n * `restore` takes a NUMBER because the SDK's `RestoreOptions` does, while the\n * tombstone stores the generation as a string — a GCS generation is a\n * microsecond timestamp (~1.7e15), comfortably inside a double, but a string\n * is what survives JSON, a Firestore round-trip and a log line without anyone\n * having to reason about that. The conversion happens once, at the call.\n */\nexport interface TombstoneStorageFile {\n getMetadata(): Promise<any>\n delete(): Promise<any>\n exists(): Promise<any>\n restore(options: { generation: number }): Promise<any>\n}\n\n/** The slice of `Bucket` this module needs. */\nexport interface TombstoneStorageBucket {\n file(path: string): TombstoneStorageFile\n}\n\n/** When a tombstone written now stops being useful. */\nexport function mediaTombstoneExpiry(deletedAtMs: number): number {\n return deletedAtMs + MEDIA_TOMBSTONE_RETENTION_MS\n}\n\n/** Milliseconds out of a Firestore Timestamp or a plain number. */\nfunction toMillis(value: unknown): number {\n const timestamp = value as { toMillis?: () => number }\n if (typeof timestamp?.toMillis === 'function') return timestamp.toMillis()\n return Number(value ?? 0)\n}\n\n/**\n * Whether the bytes this tombstone addresses are past the bucket's window.\n *\n * Missing or unreadable reads as EXPIRED, which is the safe direction: the\n * failure mode of a false positive is a refusal with a clear message, and the\n * failure mode of a false negative is a restore that half-succeeds against\n * bytes the bucket has already reaped.\n */\nexport function isMediaTombstoneExpired(\n tombstone: Pick<MediaTombstoneDoc, 'expiresAt'>,\n nowMs: number,\n): boolean {\n return !(toMillis(tombstone.expiresAt) > nowMs)\n}\n\n/**\n * Read the generation of each object, best-effort (AGL-1467).\n *\n * Must run BEFORE the delete. Once the object is soft-deleted there is no live\n * generation to read, and the number is the only handle `restore()` takes —\n * this is the single fact whose absence made undo impossible rather than\n * merely unbuilt.\n *\n * A failure here degrades the tombstone to its document half rather than\n * failing the delete: an author who asked for a file to go must not be told\n * \"no\" because a metadata read timed out.\n */\nexport async function captureObjectGenerations(\n bucket: TombstoneStorageBucket,\n paths: readonly string[],\n): Promise<MediaObjectGeneration[]> {\n return Promise.all(\n paths.map(async (path) => {\n try {\n const response = await bucket.file(path).getMetadata()\n const metadata = Array.isArray(response) ? response[0] : response\n const generation = metadata?.generation\n return {\n path,\n generation:\n generation === undefined || generation === null\n ? null\n : String(generation),\n }\n } catch {\n return { path, generation: null }\n }\n }),\n )\n}\n\n/**\n * Removes the delivery provider's copies of a deleted asset (AGL-2824).\n *\n * Unlike the bucket's objects they are not kept for a restore — a restore\n * copies the video again — and no tombstone records them. The library is\n * read off the scope reference (`hosts/{id}` or `orgs/{id}`). Nothing runs\n * when no provider is configured to store, and a failure is logged rather\n * than answered, like the object deletes beside it.\n */\nasync function removeDeliveryCopies(\n scopeRef: FirebaseFirestore.DocumentReference,\n mediaId: string,\n): Promise<void> {\n const collection = scopeRef.parent?.id\n if (collection !== 'hosts' && collection !== 'orgs') return\n await removeMediaDeliveryCopies({\n asset: { collection, scopeId: scopeRef.id, mediaId },\n })\n}\n\nexport interface DeleteMediaResult {\n /** False when there was no document to delete (already gone). */\n deleted: boolean\n /** Present when a tombstone was written — the id is the media id. */\n tombstone?: { mediaId: string; expiresAt: number; fileName: string }\n}\n\n/**\n * Delete a media document and leave a tombstone behind, in one commit.\n *\n * **The invariant: the media document is never destroyed except in the same\n * transaction that writes its tombstone and moves the counter.** All three\n * belong to one commit, so there is no interleaving in which the document is\n * gone and the record of it is not — which is the state the product was\n * permanently in before this issue.\n *\n * The object delete happens AFTER that commit, deliberately, and the ordering\n * is a choice between two partial failures:\n *\n * - object first: a failed commit leaves a live document pointing at bytes\n * that are already soft-deleted — a broken asset in the library that\n * nothing detects;\n * - commit first: a failed object delete leaves a tombstone whose object is\n * still live, which `restoreMediaFromTombstone` handles by skipping the\n * restore for an object that is already there.\n *\n * The second is recoverable by the code in this file. The first is not\n * recoverable by anything.\n */\nexport async function deleteMediaWithTombstone(options: {\n scopeRef: FirebaseFirestore.DocumentReference\n bucket: TombstoneStorageBucket\n mediaId: string\n /** Path of the primary object, resolved by the caller (legacy fallback). */\n objectPath: string\n /** Who asked — an erasure record with no actor is half a record. */\n uid: string\n nowMs?: number\n}): Promise<DeleteMediaResult> {\n const { scopeRef, bucket, mediaId, objectPath, uid } = options\n const nowMs = options.nowMs ?? Date.now()\n const mediaRef = scopeRef.collection('media').doc(mediaId)\n const snapshot = await mediaRef.get()\n if (!snapshot.exists) {\n // No document, so nothing to record and nothing to restore to. The object\n // sweep below still runs: an orphan object is exactly what this branch is\n // for, and it predates the tombstone.\n await bucket.file(objectPath).delete().catch(() => undefined)\n await removeDeliveryCopies(scopeRef, mediaId)\n return { deleted: false }\n }\n\n const variantWidths: number[] = snapshot.get('variants') ?? []\n const variantPaths = variantWidths.map(\n (width) => `${objectPath}__w${width}.webp`,\n )\n // BEFORE the delete — the whole point. See `captureObjectGenerations`.\n const objects = await captureObjectGenerations(bucket, [\n objectPath,\n ...variantPaths,\n ])\n\n const sizeBytes = Number(snapshot.get('sizeBytes') ?? 0)\n const fileName = String(snapshot.get('fileName') ?? mediaId)\n const expiresAt = mediaTombstoneExpiry(nowMs)\n const tombstoneRef = scopeRef\n .collection(MEDIA_TOMBSTONE_COLLECTION)\n .doc(mediaId)\n const counterRef = scopeRef.collection('counters').doc('media')\n\n const committed = await scopeRef.firestore.runTransaction(\n async (transaction) => {\n const fresh = await transaction.get(mediaRef)\n // Someone else deleted it between the read above and here. Their commit\n // wrote the tombstone; ours must not write a second one over it.\n if (!fresh.exists) return false\n const data = (fresh.data() ?? {}) as Record<string, unknown>\n const tombstone: MediaTombstoneDoc = {\n media: data,\n objects,\n sizeBytes: Number(data['sizeBytes'] ?? sizeBytes) || 0,\n fileName: String(data['fileName'] ?? fileName),\n deletedBy: uid,\n deletedAt: Timestamp.fromMillis(nowMs),\n // Timestamp, not a number: the TTL policy on\n // `mediaTombstones.expiresAt` cannot key on anything else.\n expiresAt: Timestamp.fromMillis(expiresAt),\n }\n transaction.set(tombstoneRef, tombstone)\n transaction.delete(mediaRef)\n transaction.set(\n counterRef,\n {\n bytes: FieldValue.increment(-tombstone.sizeBytes),\n count: FieldValue.increment(-1),\n },\n { merge: true },\n )\n return true\n },\n )\n\n // Object and variants last, best-effort, exactly as before — the bucket\n // keeps them for the retention window either way.\n await bucket.file(objectPath).delete().catch(() => undefined)\n await Promise.all(\n variantPaths.map((path) =>\n bucket.file(path).delete().catch(() => undefined),\n ),\n )\n await removeDeliveryCopies(scopeRef, mediaId)\n\n if (!committed) return { deleted: false }\n return { deleted: true, tombstone: { mediaId, expiresAt, fileName } }\n}\n\nexport interface RestoreMediaResult {\n ok: boolean\n /** HTTP status the route should answer with. */\n status: number\n /** A real sentence for a person — never a bare code. */\n message: string\n fileName?: string\n}\n\n/**\n * Why a restore cannot proceed, decided without touching Storage (AGL-1467).\n *\n * Pure, because these are the answers a person actually sees and the two\n * interesting ones are cheap to get wrong: an expired tombstone must fail\n * CLEANLY rather than half-succeed against bytes the bucket has already\n * reaped, and a restore that would breach the plan must say so rather than\n * push the counter past the limit in silence.\n *\n * ## The quota call\n *\n * A restore re-adds real bytes, so it is gated exactly like an upload —\n * `storagePerHostMb`, same off-by-one — and REFUSED when it would breach the\n * plan. Waiving it would let delete-then-restore launder an over-quota\n * library, which is the AGL-1471/AGL-1279 shape (a limit evaluated at a moment\n * the caller controls). Refusing is safe precisely because the tombstone\n * outlives the refusal: the asset is still restorable for the rest of the\n * window once space is freed, which is what makes \"no, and here is why\"\n * an honest answer rather than a loss.\n *\n * Note what is NOT metered: the soft-deleted bytes themselves. They sit in the\n * bucket for seven days at Aglyn's cost, and counting them would mean deleting\n * a file did not free space until a week later — which breaks the one action\n * an author takes when they hit the wall. The window is the price of offering\n * undo, not an allocation to bill back.\n */\nexport function mediaRestoreRefusal(options: {\n tombstone: Pick<MediaTombstoneDoc, 'expiresAt' | 'sizeBytes' | 'fileName'>\n nowMs: number\n /** Counter bytes as they stand now, with the asset already removed. */\n usedBytes: number\n billing: Record<string, unknown> | undefined\n}): { status: number; message: string } | null {\n const { tombstone, nowMs, usedBytes, billing } = options\n if (isMediaTombstoneExpired(tombstone, nowMs)) {\n return {\n status: 410,\n message:\n `\"${tombstone.fileName}\" was deleted more than ` +\n `${MEDIA_TOMBSTONE_RETENTION_DAYS} days ago, so its file is no ` +\n 'longer recoverable.',\n }\n }\n const usedMb = (usedBytes + Number(tombstone.sizeBytes ?? 0)) / (1024 * 1024)\n const quota = checkQuota(\n billing as never,\n 'storagePerHostMb',\n Math.ceil(usedMb) - 1,\n )\n if (!quota.allowed) {\n return {\n status: 403,\n message:\n `Restoring \"${tombstone.fileName}\" would put this library over its ` +\n `storage limit (${quota.limit} MB). Free up space and try again — ` +\n 'the file stays restorable until the window closes.',\n }\n }\n return null\n}\n\n/**\n * Put a deleted asset back: object, variants, document and counters.\n *\n * Order is the same argument as the delete, run backwards. Bytes first, then\n * the commit — so the only partial state this can leave is an object restored\n * with no document, which the tombstone (still present, because the commit is\n * what consumes it) makes a retry away from finished. The reverse order would\n * publish a document whose bytes may never arrive.\n *\n * The commit itself carries the document write, the tombstone delete and both\n * counter increments together, and it re-reads the tombstone inside the\n * transaction. That re-read is what makes a double-click harmless: the second\n * transaction finds no tombstone, commits nothing, and the caller is told the\n * file is already back rather than being charged for it twice.\n */\nexport async function restoreMediaFromTombstone(options: {\n scopeRef: FirebaseFirestore.DocumentReference\n bucket: TombstoneStorageBucket\n mediaId: string\n billing: Record<string, unknown> | undefined\n nowMs?: number\n}): Promise<RestoreMediaResult> {\n const { scopeRef, bucket, mediaId, billing } = options\n const nowMs = options.nowMs ?? Date.now()\n const mediaRef = scopeRef.collection('media').doc(mediaId)\n const tombstoneRef = scopeRef\n .collection(MEDIA_TOMBSTONE_COLLECTION)\n .doc(mediaId)\n const counterRef = scopeRef.collection('counters').doc('media')\n\n const tombstoneSnapshot = await tombstoneRef.get()\n if (!tombstoneSnapshot.exists) {\n // Either it was never written, or the window closed and the TTL swept it.\n // Both are the same fact to the caller, and neither is an error state the\n // product can act on.\n const media = await mediaRef.get()\n if (media.exists) {\n return {\n ok: true,\n status: 200,\n message: 'That file is already back in the library.',\n fileName: String(media.get('fileName') ?? mediaId),\n }\n }\n return {\n ok: false,\n status: 404,\n message: 'That file can no longer be restored.',\n }\n }\n\n const tombstone = tombstoneSnapshot.data() as MediaTombstoneDoc\n const counter = await counterRef.get()\n const usedBytes = Number(counter.get('bytes') ?? 0)\n const refusal = mediaRestoreRefusal({\n tombstone,\n nowMs,\n usedBytes,\n billing,\n })\n if (refusal) {\n // An expired tombstone is dead weight — a copy of customer data whose only\n // possible future is this refusal. Reap it here rather than waiting on a\n // TTL sweep that is documented as unprompt.\n if (refusal.status === 410) {\n await tombstoneRef.delete().catch(() => undefined)\n }\n return { ok: false, ...refusal, fileName: tombstone.fileName }\n }\n\n const objects = Array.isArray(tombstone.objects) ? tombstone.objects : []\n const [primary, ...variants] = objects\n const restoreObject = async (\n object: MediaObjectGeneration,\n ): Promise<boolean> => {\n if (!object?.generation) return false\n const file = bucket.file(object.path)\n // Already live — the delete's object sweep is best-effort, so this is a\n // real branch, not a defensive one. `restore()` on a live generation is\n // not a no-op, so the check has to come first.\n const existing = await file.exists().catch(() => [false])\n if (Array.isArray(existing) ? existing[0] : existing) return true\n await file.restore({ generation: Number(object.generation) })\n return true\n }\n\n if (primary) {\n try {\n await restoreObject(primary)\n } catch (error) {\n console.error(\n `restoreMediaFromTombstone: object restore failed for ${mediaId}`,\n error,\n )\n return {\n ok: false,\n status: 502,\n message:\n `\"${tombstone.fileName}\" could not be restored — its file could ` +\n 'not be recovered from storage. Nothing was changed.',\n fileName: tombstone.fileName,\n }\n }\n }\n // Variants are derived artifacts the platform can regenerate, and the upload\n // path already refuses to fail an asset for them. Same call here.\n for (const variant of variants) {\n await restoreObject(variant).catch((error) => {\n console.error(\n `restoreMediaFromTombstone: variant ${variant?.path} failed`,\n error,\n )\n return false\n })\n }\n\n const restored = await scopeRef.firestore.runTransaction(\n async (transaction) => {\n const fresh = await transaction.get(tombstoneRef)\n if (!fresh.exists) return false\n const record = fresh.data() as MediaTombstoneDoc\n // Without its delivery copy record: the delete removed those copies,\n // and a record of them would name objects that are gone (AGL-2824).\n // With the library's filter keys re-derived, so a file deleted before\n // they existed comes back findable by them (AGL-3327).\n const media = withoutMediaDeliveryCopies(record.media)\n transaction.set(mediaRef, {\n ...media,\n ...mediaFilterKeys(media as Parameters<typeof mediaFilterKeys>[0]),\n folderId: (media as { folderId?: unknown }).folderId ?? null,\n })\n transaction.delete(tombstoneRef)\n transaction.set(\n counterRef,\n {\n bytes: FieldValue.increment(Number(record.sizeBytes ?? 0)),\n count: FieldValue.increment(1),\n },\n { merge: true },\n )\n return true\n },\n )\n\n return {\n ok: true,\n status: 200,\n message: restored\n ? `Restored \"${tombstone.fileName}\".`\n : 'That file is already back in the library.',\n fileName: tombstone.fileName,\n }\n}\n"],"names":["FieldValue","Timestamp","checkQuota","mediaFilterKeys","removeMediaDeliveryCopies","withoutMediaDeliveryCopies","MEDIA_TOMBSTONE_COLLECTION","MEDIA_TOMBSTONE_RETENTION_MS","MEDIA_TOMBSTONE_RETENTION_DAYS","Math","round","mediaTombstoneExpiry","deletedAtMs","toMillis","value","timestamp","Number","isMediaTombstoneExpired","tombstone","nowMs","expiresAt","captureObjectGenerations","bucket","paths","Promise","all","map","path","response","file","getMetadata","metadata","Array","isArray","generation","undefined","String","removeDeliveryCopies","scopeRef","mediaId","collection","parent","id","asset","scopeId","deleteMediaWithTombstone","options","snapshot","objectPath","uid","Date","now","mediaRef","doc","get","exists","delete","catch","deleted","variantWidths","variantPaths","width","objects","sizeBytes","fileName","tombstoneRef","counterRef","committed","firestore","runTransaction","transaction","fresh","data","media","deletedBy","deletedAt","fromMillis","set","bytes","increment","count","merge","mediaRestoreRefusal","usedBytes","billing","status","message","usedMb","quota","ceil","allowed","limit","restoreMediaFromTombstone","counter","tombstoneSnapshot","ok","refusal","primary","variants","restoreObject","object","existing","restore","error","console","variant","restored","record","folderId"],"mappings":"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"}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { AglynOrgMember } from '@aglyn/aglyn/server';
18
+ /** The member fields `orgMemberListFields` derives from. */
19
+ export type OrgMemberListSource = Pick<Partial<AglynOrgMember>, 'role' | 'allHosts' | 'hostAccess' | 'displayName' | 'email'> & {
20
+ /** The job title the members route stores (AGL-364). */
21
+ title?: unknown;
22
+ };
23
+ /** The words the roster's search box finds a member by. */
24
+ export declare function orgMemberSearchTokens(member: OrgMemberListSource | null | undefined): string[];
25
+ /** Every field the roster's query reads that a member does not store as such. */
26
+ export declare function orgMemberListFields(member: OrgMemberListSource | null | undefined): {
27
+ consoleUserType: 'manager' | 'collaborator';
28
+ searchTokens: string[];
29
+ };
30
+ /** The fields `orgMemberListFields` writes, for a `mergeFields` write. */
31
+ export declare const ORG_MEMBER_LIST_FIELD_NAMES: readonly ["consoleUserType", "searchTokens"];
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { addressSearchWords } from "@aglyn/aglyn/app-utils/activity-search";
17
+ import { nameSearchTokens } from "@aglyn/aglyn/app-utils/name-search";
18
+ import { consoleUserType } from "@aglyn/aglyn/app-utils/organizations";
19
+ /** The words the roster's search box finds a member by. */ export function orgMemberSearchTokens(member) {
20
+ const text = (value)=>typeof value === 'string' ? value.trim() : '';
21
+ return nameSearchTokens([
22
+ text(member == null ? void 0 : member.displayName),
23
+ ...addressSearchWords(member == null ? void 0 : member.email),
24
+ text(member == null ? void 0 : member.title)
25
+ ].filter(Boolean).join(' '));
26
+ }
27
+ /** Every field the roster's query reads that a member does not store as such. */ export function orgMemberListFields(member) {
28
+ return {
29
+ consoleUserType: consoleUserType(member),
30
+ searchTokens: orgMemberSearchTokens(member)
31
+ };
32
+ }
33
+ /** The fields `orgMemberListFields` writes, for a `mergeFields` write. */ export const ORG_MEMBER_LIST_FIELD_NAMES = [
34
+ 'consoleUserType',
35
+ 'searchTokens'
36
+ ];
37
+
38
+ //# sourceMappingURL=org-member-list-fields.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/org-member-list-fields.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { addressSearchWords } from '@aglyn/aglyn/app-utils/activity-search'\nimport { nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport type { AglynOrgMember } from '@aglyn/aglyn/server'\nimport { consoleUserType } from '@aglyn/aglyn/app-utils/organizations'\n\n/*\n * WHAT THE TEAM ROSTER IS FILTERED AND SEARCHED BY (AGL-3321).\n *\n * The organization's member list (`orgs/{orgId}/members`, the Members card\n * in organization settings) filters by Role and by Access, and searches a\n * member's name, address and job title — all on the roster's Firestore query\n * (`GET /api/orgs/members` with `filters` or `search`). Two of those are not\n * stored as such on a member document, so every member carries them:\n *\n * consoleUserType `manager` or `collaborator`, as `consoleUserType` reads\n * the member's reach. That verdict is DERIVED — a legacy\n * member with neither `allHosts` nor `hostAccess` is a\n * manager — so a query cannot ask the raw fields for it.\n * searchTokens the word-prefix tokens of the display name, the address\n * (its halves and its runs, `addressSearchWords`) and the\n * job title.\n *\n * Stamped by `syncOrgAuthProjections` — the pass every membership write\n * already reaches, which recomputes the rules projection on the same\n * documents — by `createOrganization` for the owner it creates, and by\n * `backfillMemberIdentity` when it fills a blank name.\n * `tools/scripts/backfill-org-member-list-fields.mjs` stamps the roster\n * written before AGL-3321, through its script-side twin, held to\n * `tools/scripts/lib/org-member-list-fields.fixtures.json`.\n *\n * A list mirror, not an authority: nothing decides access from these.\n */\n\n/** The member fields `orgMemberListFields` derives from. */\nexport type OrgMemberListSource = Pick<\n Partial<AglynOrgMember>,\n 'role' | 'allHosts' | 'hostAccess' | 'displayName' | 'email'\n> & {\n /** The job title the members route stores (AGL-364). */\n title?: unknown\n}\n\n/** The words the roster's search box finds a member by. */\nexport function orgMemberSearchTokens(member: OrgMemberListSource | null | undefined): string[] {\n const text = (value: unknown) => (typeof value === 'string' ? value.trim() : '')\n return nameSearchTokens(\n [\n text(member?.displayName),\n ...addressSearchWords(member?.email),\n text(member?.title),\n ]\n .filter(Boolean)\n .join(' '),\n )\n}\n\n/** Every field the roster's query reads that a member does not store as such. */\nexport function orgMemberListFields(member: OrgMemberListSource | null | undefined): {\n consoleUserType: 'manager' | 'collaborator'\n searchTokens: string[]\n} {\n return {\n consoleUserType: consoleUserType(member),\n searchTokens: orgMemberSearchTokens(member),\n }\n}\n\n/** The fields `orgMemberListFields` writes, for a `mergeFields` write. */\nexport const ORG_MEMBER_LIST_FIELD_NAMES = ['consoleUserType', 'searchTokens'] as const\n"],"names":["addressSearchWords","nameSearchTokens","consoleUserType","orgMemberSearchTokens","member","text","value","trim","displayName","email","title","filter","Boolean","join","orgMemberListFields","searchTokens","ORG_MEMBER_LIST_FIELD_NAMES"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,kBAAkB,QAAQ,yCAAwC;AAC3E,SAASC,gBAAgB,QAAQ,qCAAoC;AAErE,SAASC,eAAe,QAAQ,uCAAsC;AAuCtE,yDAAyD,GACzD,OAAO,SAASC,sBAAsBC,MAA8C;IAClF,MAAMC,OAAO,CAACC,QAAoB,OAAOA,UAAU,WAAWA,MAAMC,IAAI,KAAK;IAC7E,OAAON,iBACL;QACEI,KAAKD,0BAAAA,OAAQI,WAAW;WACrBR,mBAAmBI,0BAAAA,OAAQK,KAAK;QACnCJ,KAAKD,0BAAAA,OAAQM,KAAK;KACnB,CACEC,MAAM,CAACC,SACPC,IAAI,CAAC;AAEZ;AAEA,+EAA+E,GAC/E,OAAO,SAASC,oBAAoBV,MAA8C;IAIhF,OAAO;QACLF,iBAAiBA,gBAAgBE;QACjCW,cAAcZ,sBAAsBC;IACtC;AACF;AAEA,wEAAwE,GACxE,OAAO,MAAMY,8BAA8B;IAAC;IAAmB;CAAe,CAAS"}
@@ -21,6 +21,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
21
21
  * client-writable (docs/MULTI_TENANT_FIRESTORE.md §8).
22
22
  */ import { consentGroupForHost, checkHostCollaboratorQuota, checkSeatQuota, countCollaboratorSeats, countManagerSeatsExcluding, createResourceUid, generateOrgSlug, projectMemberResolvedPermissions, resolveOrgPermissions, isOrgWideMember, isValidOrgSlug, hostPermissionKeys, orgPermissionLabel, pluginOrgPermissionKeys, projectHostMemberPermissions, projectHostMemberRoles, projectMemberScopeTokens, resolveCollaboratorHostPermissions, scopeTokensForHost } from "@aglyn/aglyn/server";
23
23
  import { ACCOUNT_ACQUISITION_FIELD, organizationAcquisition } from "@aglyn/aglyn/app-utils/account-acquisition";
24
+ import { activitySearchTokens } from "@aglyn/aglyn/app-utils/activity-search";
24
25
  import { nameSearchKey, nameSearchReversed, nameSearchTokens } from "@aglyn/aglyn/app-utils/name-search";
25
26
  // LEAF MODULE, NOT THE BARREL (AGL-1289). This file is itself reachable
26
27
  // through `@aglyn/aglyn/server`, and the verdict route proved this week that a
@@ -34,6 +35,7 @@ import { findUserByUidAcrossPools } from "./auth-pools.js";
34
35
  import firebaseAdmin from "./firebase-admin.js";
35
36
  import { enforceFreeWorkspaceCapInTransaction, readFreeWorkspaceCapConfig } from "./free-workspace-cap.js";
36
37
  import { deleteMemberHostProjections, syncHostProjectionForMembers, syncMemberHostProjections } from "./host-memberships.js";
38
+ import { ORG_MEMBER_LIST_FIELD_NAMES, orgMemberListFields, orgMemberSearchTokens } from "./org-member-list-fields.js";
37
39
  import { updateExisting } from "./update-existing.js";
38
40
  import { attachWorkspaceDomain } from "./workspace-domains.js";
39
41
  const firestore = ()=>firebaseAdmin.app().firestore();
@@ -252,7 +254,7 @@ reservation, now = Date.now()) {
252
254
  createdAt: FieldValue.serverTimestamp(),
253
255
  updatedAt: FieldValue.serverTimestamp()
254
256
  });
255
- tx.set(db.collection('orgs').doc(orgId).collection('members').doc(ownerUid), {
257
+ tx.set(db.collection('orgs').doc(orgId).collection('members').doc(ownerUid), _extends({
256
258
  role: 'owner',
257
259
  allHosts: true,
258
260
  email: ownerEmail != null ? ownerEmail : null,
@@ -273,7 +275,13 @@ reservation, now = Date.now()) {
273
275
  */ scopeTokens: projectMemberScopeTokens({
274
276
  role: 'owner',
275
277
  allHosts: true
276
- }),
278
+ })
279
+ }, orgMemberListFields({
280
+ role: 'owner',
281
+ allHosts: true,
282
+ email: ownerEmail != null ? ownerEmail : undefined,
283
+ displayName: ownerDisplayName != null ? ownerDisplayName : undefined
284
+ }), {
277
285
  /*
278
286
  * The permission projection, stamped here for the same reason and
279
287
  * with the same consequence if it is missed.
@@ -288,7 +296,7 @@ reservation, now = Date.now()) {
288
296
  role: 'owner',
289
297
  allHosts: true
290
298
  }, null)
291
- });
299
+ }));
292
300
  tx.set(db.collection('users').doc(ownerUid).collection('orgs').doc(orgId), // The owner reaches every site by definition (AGL-1032).
293
301
  {
294
302
  role: 'owner',
@@ -778,7 +786,8 @@ export async function listOrgMembers(orgId) {
778
786
  const MEMBER_PROJECTION_WRITE = {
779
787
  mergeFields: [
780
788
  'scopeTokens',
781
- 'resolvedPermissions'
789
+ 'resolvedPermissions',
790
+ ...ORG_MEMBER_LIST_FIELD_NAMES
782
791
  ]
783
792
  };
784
793
  /**
@@ -847,7 +856,7 @@ const MEMBER_PROJECTION_WRITE = {
847
856
  var _customRoles_get;
848
857
  return [
849
858
  orgRef.collection('members').doc(member.$id),
850
- {
859
+ _extends({
851
860
  scopeTokens: projectMemberScopeTokens(member),
852
861
  resolvedPermissions: projectMemberResolvedPermissions(member, // `?? null`, never `?? undefined`: a member whose `roleId`
853
862
  // points at a DELETED role must resolve to their role
@@ -856,7 +865,7 @@ const MEMBER_PROJECTION_WRITE = {
856
865
  // dangling id. Leaving it undefined would be the same value,
857
866
  // but the null says the lookup happened and missed.
858
867
  member.roleId ? (_customRoles_get = customRoles.get(member.roleId)) != null ? _customRoles_get : null : null)
859
- },
868
+ }, orgMemberListFields(member)),
860
869
  MEMBER_PROJECTION_WRITE
861
870
  ];
862
871
  })
@@ -904,6 +913,11 @@ const MEMBER_PROJECTION_WRITE = {
904
913
  } : {}, target.versionId ? {
905
914
  versionId: target.versionId
906
915
  } : {}),
916
+ // What the log's search box finds the entry by (AGL-3321).
917
+ searchTokens: activitySearchTokens({
918
+ actorEmail: actor.email,
919
+ target
920
+ }),
907
921
  createdAt: FieldValue.serverTimestamp()
908
922
  }).catch(()=>undefined);
909
923
  }
@@ -945,6 +959,13 @@ const MEMBER_PROJECTION_WRITE = {
945
959
  } : {}, target.versionId ? {
946
960
  versionId: target.versionId
947
961
  } : {}),
962
+ // What the log's search box finds the entry by (AGL-3321): the key's
963
+ // name stands in for an address an integration does not have.
964
+ searchTokens: activitySearchTokens({
965
+ actorEmail: actor.email,
966
+ apiKeyName: actor.apiKeyName,
967
+ target
968
+ }),
948
969
  createdAt: FieldValue.serverTimestamp()
949
970
  })).catch(()=>undefined);
950
971
  }
@@ -1472,7 +1493,16 @@ const MEMBER_PROJECTION_WRITE = {
1472
1493
  patch['photoURL'] = photoURL;
1473
1494
  }
1474
1495
  if (!Object.keys(patch).length) return [];
1475
- await ref.set(patch, {
1496
+ // A filled name is a name the roster's search finds (AGL-3321); a photo
1497
+ // alone changes nothing the search reads.
1498
+ const searchTokens = patch['displayName'] ? {
1499
+ searchTokens: orgMemberSearchTokens({
1500
+ displayName: patch['displayName'],
1501
+ email: snapshot.get('email'),
1502
+ title: snapshot.get('title')
1503
+ })
1504
+ } : {};
1505
+ await ref.set(_extends({}, patch, searchTokens), {
1476
1506
  merge: true
1477
1507
  });
1478
1508
  return Object.keys(patch);