@aglyn/tenant-data-admin 1.0.0-beta.205 → 1.0.0-beta.206

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/tenant-data-admin",
3
- "version": "1.0.0-beta.205",
3
+ "version": "1.0.0-beta.206",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,12 +25,12 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.205",
29
- "@aglyn/shared-util-email": "1.0.0-beta.205",
30
- "@aglyn/shared-util-fbserver": "1.0.0-beta.205",
31
- "@aglyn/shared-util-first-touch": "1.0.0-beta.205",
32
- "@aglyn/shared-util-http": "1.0.0-beta.205",
33
- "@aglyn/shared-util-tools": "1.0.0-beta.205",
28
+ "@aglyn/aglyn": "1.0.0-beta.206",
29
+ "@aglyn/shared-util-email": "1.0.0-beta.206",
30
+ "@aglyn/shared-util-fbserver": "1.0.0-beta.206",
31
+ "@aglyn/shared-util-first-touch": "1.0.0-beta.206",
32
+ "@aglyn/shared-util-http": "1.0.0-beta.206",
33
+ "@aglyn/shared-util-tools": "1.0.0-beta.206",
34
34
  "@msgpack/msgpack": "^3.1.3",
35
35
  "@swc/helpers": "0.5.23",
36
36
  "sharp": "^0.35.3",
@@ -23,6 +23,8 @@ interface HostMeta {
23
23
  cname?: string;
24
24
  /** When the site was created: set once, by `claimHostForOrg`, and never moved. */
25
25
  createdAt?: unknown;
26
+ /** Whether the site document was there to read. */
27
+ exists?: boolean;
26
28
  }
27
29
  /**
28
30
  * The words a site is FOUND by in the Sites list's quick search (AGL-3321):
@@ -51,6 +53,24 @@ export declare function hostMembershipSearchTokens(meta: Pick<HostMeta, 'display
51
53
  * detach route removes.
52
54
  */
53
55
  export declare const hasCustomDomain: (meta: Pick<HostMeta, "cname"> | undefined) => boolean;
56
+ /**
57
+ * The keys the staff Sites list (`/admin/sites`) queries on the SITE document
58
+ * itself: the same name key, search tokens and custom-domain flag the
59
+ * membership rows carry, so staff find a site by its name, subdomain or
60
+ * domain across every organization without walking anyone's projection.
61
+ *
62
+ * Stamped by `syncHostProjectionForMembers` — the funnel every create,
63
+ * rename, domain attach and domain release already calls — and by
64
+ * `tools/scripts/backfill-host-search-fields.mjs` on the sites written before
65
+ * it. `hasCustomDomain` is stored as `false`, never left out, for the reason
66
+ * the membership row gives: a query cannot find a document by a field it
67
+ * lacks.
68
+ */
69
+ export declare const hostSearchFields: (meta: Pick<HostMeta, "displayName" | "subdomain" | "cname"> | undefined) => {
70
+ nameLower: string;
71
+ searchTokens: string[];
72
+ hasCustomDomain: boolean;
73
+ };
54
74
  /**
55
75
  * Shape one projection row. Exported for tests: the favicon rule below is a
56
76
  * delete-vs-omit decision that is invisible in review and silent in
@@ -80,7 +80,8 @@ const readHostMeta = async (db, hostIds)=>{
80
80
  // favicon has to travel with it (AGL-1071).
81
81
  favicon: (_snap_get = snap.get('seo')) == null ? void 0 : _snap_get.favicon,
82
82
  cname: snap.get('cname'),
83
- createdAt: snap.get('createdAt')
83
+ createdAt: snap.get('createdAt'),
84
+ exists: snap.exists
84
85
  }
85
86
  ];
86
87
  }));
@@ -143,6 +144,26 @@ const membershipRef = (db, uid, hostId)=>db.collection('users').doc(uid).collect
143
144
  * a non-blank `cname`, which only the domain attach route writes and only the
144
145
  * detach route removes.
145
146
  */ export const hasCustomDomain = (meta)=>typeof (meta == null ? void 0 : meta.cname) === 'string' && meta.cname.trim() !== '';
147
+ /**
148
+ * The keys the staff Sites list (`/admin/sites`) queries on the SITE document
149
+ * itself: the same name key, search tokens and custom-domain flag the
150
+ * membership rows carry, so staff find a site by its name, subdomain or
151
+ * domain across every organization without walking anyone's projection.
152
+ *
153
+ * Stamped by `syncHostProjectionForMembers` — the funnel every create,
154
+ * rename, domain attach and domain release already calls — and by
155
+ * `tools/scripts/backfill-host-search-fields.mjs` on the sites written before
156
+ * it. `hasCustomDomain` is stored as `false`, never left out, for the reason
157
+ * the membership row gives: a query cannot find a document by a field it
158
+ * lacks.
159
+ */ export const hostSearchFields = (meta)=>{
160
+ var _ref;
161
+ return {
162
+ nameLower: nameSearchKey((_ref = meta == null ? void 0 : meta.displayName) != null ? _ref : ''),
163
+ searchTokens: hostMembershipSearchTokens(meta),
164
+ hasCustomDomain: hasCustomDomain(meta)
165
+ };
166
+ };
146
167
  /** A Firestore timestamp, as opposed to a legacy number or string. */ const isTimestamp = (value)=>typeof (value == null ? void 0 : value.toMillis) === 'function';
147
168
  /**
148
169
  * Shape one projection row. Exported for tests: the favicon rule below is a
@@ -263,6 +284,13 @@ const membershipRef = (db, uid, hostId)=>db.collection('users').doc(uid).collect
263
284
  else batch.delete(ref);
264
285
  });
265
286
  await commitChunked(db, ops);
287
+ // The staff Sites list's keys, on the site itself. `update`, never a
288
+ // merge-set: a site erased since the read must not be minted back as a
289
+ // phantom carrying only these three keys (the AGL-1765 shape). Outside the
290
+ // batch so a site gone missing cannot fail its members' rows.
291
+ if (hostMeta == null ? void 0 : hostMeta.exists) {
292
+ await db.collection('hosts').doc(hostId).update(hostSearchFields(hostMeta)).catch(()=>undefined);
293
+ }
266
294
  }
267
295
  /** Delete every projection row for a member across the org's hosts (member removed). */ export async function deleteMemberHostProjections(orgId, uid) {
268
296
  const db = firestore();
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-memberships.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 * Per-user host membership projection (AGL-844): a reverse index at\n * `users/{uid}/hostMemberships/{hostId}` mirroring `hosts/{hostId}.memberRoles`\n * — the hosts a user can reach in an org, denormalized with the name so the\n * site switcher and subdomain→id routing can query + order + name-prefix-search\n * a user's own sites without loading the whole `hosts` collection (which the\n * rules can only serve unordered under a per-user `memberRoles.{uid}` filter).\n *\n * Because it lives under the user's own doc it is owner-readable and already\n * membership-filtered, so `where(orgId==…) orderBy(nameLower|updatedAt)` is a\n * plain composite index (no dynamic map path). Admin-SDK-written only.\n *\n * It is a best-effort convenience index, NOT an authorization source: real\n * access stays gated by `hosts/{hostId}.memberRoles` in the rules, so a stale\n * row at worst shows a site that then 404s on read — never grants access.\n *\n * Writers are scoped, mirroring the `users/{uid}/orgs` reverse index: a member\n * change re-syncs THAT member across the org's hosts; a host change re-syncs\n * THAT host across members. Kept in sync at the same points as `memberRoles`\n * (see `syncHostMemberRoles` callers) plus the erase/displayName gaps (AGL-855).\n *\n * ## The Sites list is a query over these rows (AGL-3321)\n *\n * The organization's Sites list pages, filters and searches THIS collection —\n * `where(orgId ==)`, plus the reader's clauses, ordered by `nameLower` or\n * `createdAt` — and joins each row to the host document by id for display.\n * It cannot query `hosts` itself: the rules serve a non-staff LIST of `hosts`\n * only under a `memberRoles.{uid}` clause, a composite on that per-user map\n * path can exist for no order but the document id, and a predicated listen\n * over the mutable `memberRoles`/`orgId` is what tombstoned host documents\n * for every other reader (AGL-1190, `useOrgHosts`).\n *\n * So a row carries what that list asks of a site: `searchTokens` (the quick\n * search, over the name, the platform subdomain and the custom domain),\n * `hasCustomDomain` (the Custom domain filter) and the host's `createdAt`\n * (immutable, set once at create). Every field is derived from the host\n * document in `membershipRow`, so a re-sync from any writer re-derives all of\n * them; the writers of their source fields — create, the owner and staff\n * renames, the Setup name save, and the domain attach and detach routes (the\n * only writers of `cname`) — each re-sync. Rows written before AGL-3321 are\n * stamped by `tools/scripts/backfill-host-memberships-list-fields.mjs`.\n */\n\nimport {\n hostRoleFor,\n isOrgWideMember,\n nameSearchKey,\n type AglynOrganization,\n type AglynOrgMember,\n} from '@aglyn/aglyn/server'\nimport { nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/** Firestore caps a batch at 500 writes; commit in chunks below that. */\nconst CHUNK = 400\n\ninterface HostMeta {\n displayName?: string\n subdomain?: string\n favicon?: string\n /** The custom domain, which the Sites list's search finds a site by. */\n cname?: string\n /** When the site was created: set once, by `claimHostForOrg`, and never moved. */\n createdAt?: unknown\n}\n\nconst orgHostIds = async (\n db: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<string[]> => {\n const org = (await db.collection('orgs').doc(orgId).get()).data() as\n | AglynOrganization\n | undefined\n return Object.keys(org?.hosts ?? {})\n}\n\nconst readHostMeta = async (\n db: FirebaseFirestore.Firestore,\n hostIds: string[],\n): Promise<Map<string, HostMeta>> => {\n if (hostIds.length === 0) return new Map()\n const snaps = await db.getAll(\n ...hostIds.map((id) => db.collection('hosts').doc(id)),\n )\n return new Map(\n snaps.map((snap) => [\n snap.id,\n {\n displayName: snap.get('displayName'),\n subdomain: snap.get('subdomain'),\n // The switcher renders from the projection, not the host doc, so the\n // favicon has to travel with it (AGL-1071).\n favicon: snap.get('seo')?.favicon,\n cname: snap.get('cname'),\n createdAt: snap.get('createdAt'),\n },\n ]),\n )\n}\n\nconst listMembers = async (\n db: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<AglynOrgMember[]> => {\n const snap = await db\n .collection('orgs')\n .doc(orgId)\n .collection('members')\n .get()\n return snap.docs.map((d) => ({ $id: d.id, ...d.data() }) as AglynOrgMember)\n}\n\nconst membershipRef = (\n db: FirebaseFirestore.Firestore,\n uid: string,\n hostId: string,\n) => db.collection('users').doc(uid).collection('hostMemberships').doc(hostId)\n\n/** A tail of a word begins after any character that is not a letter or a digit. */\nconst WORD_SEPARATOR = /[^\\p{L}\\p{N}]/u\n\n/**\n * A word, and every tail of it that begins after a separator:\n * `shop.harbor-bakery.com` is itself, `harbor-bakery.com`, `bakery.com` and\n * `com`. Walked by codepoint, so a tail never starts inside a surrogate pair.\n */\nconst wordTails = (word: string): string[] => {\n const chars = [...word]\n const tails = [word]\n for (let at = 1; at < chars.length; at += 1) {\n if (WORD_SEPARATOR.test(chars[at - 1]) && !WORD_SEPARATOR.test(chars[at])) {\n tails.push(chars.slice(at).join(''))\n }\n }\n return tails\n}\n\n/**\n * The words a site is FOUND by in the Sites list's quick search (AGL-3321):\n * its name, its platform subdomain and its custom domain, as the word-prefix\n * tokens `nameSearchTokens` writes, which the list's query asks for with one\n * `array-contains` on the first typed word (`nameSearchNormalizers.token`).\n *\n * A subdomain or a domain is one \"word\" to `nameSearchTokens`, so the prefixes\n * of `harbor-bakery` alone would find it by `harbor` and never by `bakery`.\n * Every tail that begins after a separator is tokenized too, which is what\n * lets `bakery`, `bakery.com` and `shop.harbor` all find\n * `shop.harbor-bakery.com` — the words a reader remembers of an address.\n *\n * The platform apex is not a word here: it is the same on every site, so it\n * narrows nothing, and the subdomain in front of it is.\n *\n * Its script-side twin, for the backfill and the seeds that write these rows,\n * is `tools/scripts/lib/host-membership-search-tokens.mjs`; both answer\n * `tools/scripts/lib/host-membership-search-tokens.fixtures.json`, which this\n * library's spec asserts too.\n */\nexport function hostMembershipSearchTokens(\n meta: Pick<HostMeta, 'displayName' | 'subdomain' | 'cname'> | undefined,\n): string[] {\n const words = [meta?.displayName, meta?.subdomain, meta?.cname]\n .map((value) => nameSearchKey(typeof value === 'string' ? value : ''))\n .flatMap((key) => (key ? key.split(' ') : []))\n .flatMap(wordTails)\n return nameSearchTokens(words.join(' '))\n}\n\n/**\n * Whether a site has a custom domain connected, as the Sites card lists it:\n * a non-blank `cname`, which only the domain attach route writes and only the\n * detach route removes.\n */\nexport const hasCustomDomain = (meta: Pick<HostMeta, 'cname'> | undefined): boolean =>\n typeof meta?.cname === 'string' && meta.cname.trim() !== ''\n\n/** A Firestore timestamp, as opposed to a legacy number or string. */\nconst isTimestamp = (value: unknown): boolean =>\n typeof (value as { toMillis?: unknown } | null | undefined)?.toMillis === 'function'\n\n/**\n * Shape one projection row. Exported for tests: the favicon rule below is a\n * delete-vs-omit decision that is invisible in review and silent in\n * production when wrong (AGL-1071).\n */\nexport const membershipRow = (\n orgId: string,\n meta: HostMeta | undefined,\n role: string,\n) => {\n const displayName = meta?.displayName ?? ''\n return {\n orgId,\n ...(meta?.subdomain ? { subdomain: meta.subdomain } : {}),\n displayName,\n nameLower: nameSearchKey(displayName),\n // What the Sites list searches (AGL-3321). Re-derived on every sync, so a\n // rename, a domain attach or a domain release moves it with the host.\n searchTokens: hostMembershipSearchTokens(meta),\n // The Custom domain filter (AGL-3321): an equality, which every order the\n // list takes can carry. Stored as `false`, never left out, because a query\n // cannot find a document by a field it lacks.\n hasCustomDomain: hasCustomDomain(meta),\n // What the Sites list's Created filter and order read. NULL rather than\n // absent for a site that never recorded one: `orderBy('createdAt')` drops\n // a document MISSING the field, and a site must not vanish from the list\n // because it is sorted by a date it does not have.\n createdAt: isTimestamp(meta?.createdAt) ? meta?.createdAt : null,\n // DELETE rather than omit when the site has no favicon (AGL-1071). These\n // rows are written with `{ merge: true }`, so omitting the key leaves\n // whatever was there — clearing a favicon would keep showing the old one\n // in the switcher until something unrelated rewrote the row. The clear\n // path writes `seo.favicon: ''`, so test truthiness, not nullishness.\n favicon: meta?.favicon ? meta.favicon : FieldValue.delete(),\n role,\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n\n/** Runs a set of write operations in ≤CHUNK-sized batches. */\nconst commitChunked = async (\n db: FirebaseFirestore.Firestore,\n ops: Array<(batch: FirebaseFirestore.WriteBatch) => void>,\n): Promise<void> => {\n for (let i = 0; i < ops.length; i += CHUNK) {\n const batch = db.batch()\n for (const op of ops.slice(i, i + CHUNK)) op(batch)\n await batch.commit()\n }\n}\n\n/**\n * Mirror the member's org-wide reach onto their `users/{uid}/orgs/{orgId}`\n * row (AGL-1032), so the console can tell a site collaborator from an\n * org-wide viewer without a second read on every org route.\n *\n * Rides the projection pass rather than being a call the membership APIs\n * each have to remember: every path that changes a member's reach — add,\n * role change, grant, revoke, ownership transfer — already re-syncs their\n * host rows, so one funnel keeps the mirror true and self-heals a row an\n * earlier failure left stale.\n *\n * Only ever UPDATES an existing row. The reverse index is created with the\n * membership and deleted with it; a `set`/merge here on a removed member\n * would resurrect a half-row with no `orgName` or `slug`, and the console\n * renders that as a nameless workspace nobody can open.\n */\nasync function syncMemberOrgReach(\n db: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string,\n member: AglynOrgMember | null,\n): Promise<void> {\n if (!member) return\n const ref = db.collection('users').doc(uid).collection('orgs').doc(orgId)\n const snap = await ref.get()\n if (!snap.exists) return\n const orgWide = isOrgWideMember(member)\n if (snap.get('orgWide') === orgWide) return\n await ref.set({ orgWide }, { merge: true })\n}\n\n/**\n * Re-sync ONE member's projection rows across all the org's hosts: set a row\n * for every host the member can reach, delete the row for every host they\n * cannot (a role downgrade silently drops hosts). Call after any change to a\n * single member (add / role change / grant / revoke / ownership step).\n */\nexport async function syncMemberHostProjections(\n orgId: string,\n uid: string,\n): Promise<void> {\n const db = firestore()\n const [memberSnap, hostIds] = await Promise.all([\n db.collection('orgs').doc(orgId).collection('members').doc(uid).get(),\n orgHostIds(db, orgId),\n ])\n const member = memberSnap.exists\n ? ({ $id: uid, ...memberSnap.data() } as AglynOrgMember)\n : null\n await syncMemberOrgReach(db, orgId, uid, member)\n const meta = await readHostMeta(db, hostIds)\n const ops = hostIds.map((hostId) => (batch: FirebaseFirestore.WriteBatch) => {\n const role = hostRoleFor(member, hostId)\n const ref = membershipRef(db, uid, hostId)\n if (role) batch.set(ref, membershipRow(orgId, meta.get(hostId), role), { merge: true })\n else batch.delete(ref)\n })\n await commitChunked(db, ops)\n}\n\n/**\n * Re-sync ONE host's projection rows across all org members: set a row for\n * every member who can reach it, delete for those who cannot. Call after a\n * host is created, renamed, or its displayName changes.\n */\nexport async function syncHostProjectionForMembers(\n orgId: string,\n hostId: string,\n): Promise<void> {\n const db = firestore()\n const [members, meta] = await Promise.all([\n listMembers(db, orgId),\n readHostMeta(db, [hostId]),\n ])\n const hostMeta = meta.get(hostId)\n const ops = members.map((member) => (batch: FirebaseFirestore.WriteBatch) => {\n const role = hostRoleFor(member, hostId)\n const ref = membershipRef(db, member.$id, hostId)\n if (role) batch.set(ref, membershipRow(orgId, hostMeta, role), { merge: true })\n else batch.delete(ref)\n })\n await commitChunked(db, ops)\n}\n\n/** Delete every projection row for a member across the org's hosts (member removed). */\nexport async function deleteMemberHostProjections(\n orgId: string,\n uid: string,\n): Promise<void> {\n const db = firestore()\n const hostIds = await orgHostIds(db, orgId)\n await commitChunked(\n db,\n hostIds.map((hostId) => (batch: FirebaseFirestore.WriteBatch) => {\n batch.delete(membershipRef(db, uid, hostId))\n }),\n )\n}\n\n/** Delete every member's projection row for one host (host erased). */\nexport async function deleteHostProjectionForAllMembers(\n orgId: string,\n hostId: string,\n): Promise<void> {\n const db = firestore()\n const members = await listMembers(db, orgId)\n await commitChunked(\n db,\n members.map((member) => (batch: FirebaseFirestore.WriteBatch) => {\n batch.delete(membershipRef(db, member.$id, hostId))\n }),\n )\n}\n"],"names":["hostRoleFor","isOrgWideMember","nameSearchKey","nameSearchTokens","FieldValue","firebaseAdmin","firestore","app","CHUNK","orgHostIds","db","orgId","org","collection","doc","get","data","Object","keys","hosts","readHostMeta","hostIds","length","Map","snaps","getAll","map","id","snap","displayName","subdomain","favicon","cname","createdAt","listMembers","docs","d","$id","membershipRef","uid","hostId","WORD_SEPARATOR","wordTails","word","chars","tails","at","test","push","slice","join","hostMembershipSearchTokens","meta","words","value","flatMap","key","split","hasCustomDomain","trim","isTimestamp","toMillis","membershipRow","role","nameLower","searchTokens","delete","updatedAt","serverTimestamp","commitChunked","ops","i","batch","op","commit","syncMemberOrgReach","member","ref","exists","orgWide","set","merge","syncMemberHostProjections","memberSnap","Promise","all","syncHostProjectionForMembers","members","hostMeta","deleteMemberHostProjections","deleteHostProjectionForAllMembers"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-memberships.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 * Per-user host membership projection (AGL-844): a reverse index at\n * `users/{uid}/hostMemberships/{hostId}` mirroring `hosts/{hostId}.memberRoles`\n * — the hosts a user can reach in an org, denormalized with the name so the\n * site switcher and subdomain→id routing can query + order + name-prefix-search\n * a user's own sites without loading the whole `hosts` collection (which the\n * rules can only serve unordered under a per-user `memberRoles.{uid}` filter).\n *\n * Because it lives under the user's own doc it is owner-readable and already\n * membership-filtered, so `where(orgId==…) orderBy(nameLower|updatedAt)` is a\n * plain composite index (no dynamic map path). Admin-SDK-written only.\n *\n * It is a best-effort convenience index, NOT an authorization source: real\n * access stays gated by `hosts/{hostId}.memberRoles` in the rules, so a stale\n * row at worst shows a site that then 404s on read — never grants access.\n *\n * Writers are scoped, mirroring the `users/{uid}/orgs` reverse index: a member\n * change re-syncs THAT member across the org's hosts; a host change re-syncs\n * THAT host across members. Kept in sync at the same points as `memberRoles`\n * (see `syncHostMemberRoles` callers) plus the erase/displayName gaps (AGL-855).\n *\n * ## The Sites list is a query over these rows (AGL-3321)\n *\n * The organization's Sites list pages, filters and searches THIS collection —\n * `where(orgId ==)`, plus the reader's clauses, ordered by `nameLower` or\n * `createdAt` — and joins each row to the host document by id for display.\n * It cannot query `hosts` itself: the rules serve a non-staff LIST of `hosts`\n * only under a `memberRoles.{uid}` clause, a composite on that per-user map\n * path can exist for no order but the document id, and a predicated listen\n * over the mutable `memberRoles`/`orgId` is what tombstoned host documents\n * for every other reader (AGL-1190, `useOrgHosts`).\n *\n * So a row carries what that list asks of a site: `searchTokens` (the quick\n * search, over the name, the platform subdomain and the custom domain),\n * `hasCustomDomain` (the Custom domain filter) and the host's `createdAt`\n * (immutable, set once at create). Every field is derived from the host\n * document in `membershipRow`, so a re-sync from any writer re-derives all of\n * them; the writers of their source fields — create, the owner and staff\n * renames, the Setup name save, and the domain attach and detach routes (the\n * only writers of `cname`) — each re-sync. Rows written before AGL-3321 are\n * stamped by `tools/scripts/backfill-host-memberships-list-fields.mjs`.\n */\n\nimport {\n hostRoleFor,\n isOrgWideMember,\n nameSearchKey,\n type AglynOrganization,\n type AglynOrgMember,\n} from '@aglyn/aglyn/server'\nimport { nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/** Firestore caps a batch at 500 writes; commit in chunks below that. */\nconst CHUNK = 400\n\ninterface HostMeta {\n displayName?: string\n subdomain?: string\n favicon?: string\n /** The custom domain, which the Sites list's search finds a site by. */\n cname?: string\n /** When the site was created: set once, by `claimHostForOrg`, and never moved. */\n createdAt?: unknown\n /** Whether the site document was there to read. */\n exists?: boolean\n}\n\nconst orgHostIds = async (\n db: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<string[]> => {\n const org = (await db.collection('orgs').doc(orgId).get()).data() as\n | AglynOrganization\n | undefined\n return Object.keys(org?.hosts ?? {})\n}\n\nconst readHostMeta = async (\n db: FirebaseFirestore.Firestore,\n hostIds: string[],\n): Promise<Map<string, HostMeta>> => {\n if (hostIds.length === 0) return new Map()\n const snaps = await db.getAll(\n ...hostIds.map((id) => db.collection('hosts').doc(id)),\n )\n return new Map(\n snaps.map((snap) => [\n snap.id,\n {\n displayName: snap.get('displayName'),\n subdomain: snap.get('subdomain'),\n // The switcher renders from the projection, not the host doc, so the\n // favicon has to travel with it (AGL-1071).\n favicon: snap.get('seo')?.favicon,\n cname: snap.get('cname'),\n createdAt: snap.get('createdAt'),\n exists: snap.exists,\n },\n ]),\n )\n}\n\nconst listMembers = async (\n db: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<AglynOrgMember[]> => {\n const snap = await db\n .collection('orgs')\n .doc(orgId)\n .collection('members')\n .get()\n return snap.docs.map((d) => ({ $id: d.id, ...d.data() }) as AglynOrgMember)\n}\n\nconst membershipRef = (\n db: FirebaseFirestore.Firestore,\n uid: string,\n hostId: string,\n) => db.collection('users').doc(uid).collection('hostMemberships').doc(hostId)\n\n/** A tail of a word begins after any character that is not a letter or a digit. */\nconst WORD_SEPARATOR = /[^\\p{L}\\p{N}]/u\n\n/**\n * A word, and every tail of it that begins after a separator:\n * `shop.harbor-bakery.com` is itself, `harbor-bakery.com`, `bakery.com` and\n * `com`. Walked by codepoint, so a tail never starts inside a surrogate pair.\n */\nconst wordTails = (word: string): string[] => {\n const chars = [...word]\n const tails = [word]\n for (let at = 1; at < chars.length; at += 1) {\n if (WORD_SEPARATOR.test(chars[at - 1]) && !WORD_SEPARATOR.test(chars[at])) {\n tails.push(chars.slice(at).join(''))\n }\n }\n return tails\n}\n\n/**\n * The words a site is FOUND by in the Sites list's quick search (AGL-3321):\n * its name, its platform subdomain and its custom domain, as the word-prefix\n * tokens `nameSearchTokens` writes, which the list's query asks for with one\n * `array-contains` on the first typed word (`nameSearchNormalizers.token`).\n *\n * A subdomain or a domain is one \"word\" to `nameSearchTokens`, so the prefixes\n * of `harbor-bakery` alone would find it by `harbor` and never by `bakery`.\n * Every tail that begins after a separator is tokenized too, which is what\n * lets `bakery`, `bakery.com` and `shop.harbor` all find\n * `shop.harbor-bakery.com` — the words a reader remembers of an address.\n *\n * The platform apex is not a word here: it is the same on every site, so it\n * narrows nothing, and the subdomain in front of it is.\n *\n * Its script-side twin, for the backfill and the seeds that write these rows,\n * is `tools/scripts/lib/host-membership-search-tokens.mjs`; both answer\n * `tools/scripts/lib/host-membership-search-tokens.fixtures.json`, which this\n * library's spec asserts too.\n */\nexport function hostMembershipSearchTokens(\n meta: Pick<HostMeta, 'displayName' | 'subdomain' | 'cname'> | undefined,\n): string[] {\n const words = [meta?.displayName, meta?.subdomain, meta?.cname]\n .map((value) => nameSearchKey(typeof value === 'string' ? value : ''))\n .flatMap((key) => (key ? key.split(' ') : []))\n .flatMap(wordTails)\n return nameSearchTokens(words.join(' '))\n}\n\n/**\n * Whether a site has a custom domain connected, as the Sites card lists it:\n * a non-blank `cname`, which only the domain attach route writes and only the\n * detach route removes.\n */\nexport const hasCustomDomain = (meta: Pick<HostMeta, 'cname'> | undefined): boolean =>\n typeof meta?.cname === 'string' && meta.cname.trim() !== ''\n\n/**\n * The keys the staff Sites list (`/admin/sites`) queries on the SITE document\n * itself: the same name key, search tokens and custom-domain flag the\n * membership rows carry, so staff find a site by its name, subdomain or\n * domain across every organization without walking anyone's projection.\n *\n * Stamped by `syncHostProjectionForMembers` — the funnel every create,\n * rename, domain attach and domain release already calls — and by\n * `tools/scripts/backfill-host-search-fields.mjs` on the sites written before\n * it. `hasCustomDomain` is stored as `false`, never left out, for the reason\n * the membership row gives: a query cannot find a document by a field it\n * lacks.\n */\nexport const hostSearchFields = (\n meta: Pick<HostMeta, 'displayName' | 'subdomain' | 'cname'> | undefined,\n) => ({\n nameLower: nameSearchKey(meta?.displayName ?? ''),\n searchTokens: hostMembershipSearchTokens(meta),\n hasCustomDomain: hasCustomDomain(meta),\n})\n\n/** A Firestore timestamp, as opposed to a legacy number or string. */\nconst isTimestamp = (value: unknown): boolean =>\n typeof (value as { toMillis?: unknown } | null | undefined)?.toMillis === 'function'\n\n/**\n * Shape one projection row. Exported for tests: the favicon rule below is a\n * delete-vs-omit decision that is invisible in review and silent in\n * production when wrong (AGL-1071).\n */\nexport const membershipRow = (\n orgId: string,\n meta: HostMeta | undefined,\n role: string,\n) => {\n const displayName = meta?.displayName ?? ''\n return {\n orgId,\n ...(meta?.subdomain ? { subdomain: meta.subdomain } : {}),\n displayName,\n nameLower: nameSearchKey(displayName),\n // What the Sites list searches (AGL-3321). Re-derived on every sync, so a\n // rename, a domain attach or a domain release moves it with the host.\n searchTokens: hostMembershipSearchTokens(meta),\n // The Custom domain filter (AGL-3321): an equality, which every order the\n // list takes can carry. Stored as `false`, never left out, because a query\n // cannot find a document by a field it lacks.\n hasCustomDomain: hasCustomDomain(meta),\n // What the Sites list's Created filter and order read. NULL rather than\n // absent for a site that never recorded one: `orderBy('createdAt')` drops\n // a document MISSING the field, and a site must not vanish from the list\n // because it is sorted by a date it does not have.\n createdAt: isTimestamp(meta?.createdAt) ? meta?.createdAt : null,\n // DELETE rather than omit when the site has no favicon (AGL-1071). These\n // rows are written with `{ merge: true }`, so omitting the key leaves\n // whatever was there — clearing a favicon would keep showing the old one\n // in the switcher until something unrelated rewrote the row. The clear\n // path writes `seo.favicon: ''`, so test truthiness, not nullishness.\n favicon: meta?.favicon ? meta.favicon : FieldValue.delete(),\n role,\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n\n/** Runs a set of write operations in ≤CHUNK-sized batches. */\nconst commitChunked = async (\n db: FirebaseFirestore.Firestore,\n ops: Array<(batch: FirebaseFirestore.WriteBatch) => void>,\n): Promise<void> => {\n for (let i = 0; i < ops.length; i += CHUNK) {\n const batch = db.batch()\n for (const op of ops.slice(i, i + CHUNK)) op(batch)\n await batch.commit()\n }\n}\n\n/**\n * Mirror the member's org-wide reach onto their `users/{uid}/orgs/{orgId}`\n * row (AGL-1032), so the console can tell a site collaborator from an\n * org-wide viewer without a second read on every org route.\n *\n * Rides the projection pass rather than being a call the membership APIs\n * each have to remember: every path that changes a member's reach — add,\n * role change, grant, revoke, ownership transfer — already re-syncs their\n * host rows, so one funnel keeps the mirror true and self-heals a row an\n * earlier failure left stale.\n *\n * Only ever UPDATES an existing row. The reverse index is created with the\n * membership and deleted with it; a `set`/merge here on a removed member\n * would resurrect a half-row with no `orgName` or `slug`, and the console\n * renders that as a nameless workspace nobody can open.\n */\nasync function syncMemberOrgReach(\n db: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string,\n member: AglynOrgMember | null,\n): Promise<void> {\n if (!member) return\n const ref = db.collection('users').doc(uid).collection('orgs').doc(orgId)\n const snap = await ref.get()\n if (!snap.exists) return\n const orgWide = isOrgWideMember(member)\n if (snap.get('orgWide') === orgWide) return\n await ref.set({ orgWide }, { merge: true })\n}\n\n/**\n * Re-sync ONE member's projection rows across all the org's hosts: set a row\n * for every host the member can reach, delete the row for every host they\n * cannot (a role downgrade silently drops hosts). Call after any change to a\n * single member (add / role change / grant / revoke / ownership step).\n */\nexport async function syncMemberHostProjections(\n orgId: string,\n uid: string,\n): Promise<void> {\n const db = firestore()\n const [memberSnap, hostIds] = await Promise.all([\n db.collection('orgs').doc(orgId).collection('members').doc(uid).get(),\n orgHostIds(db, orgId),\n ])\n const member = memberSnap.exists\n ? ({ $id: uid, ...memberSnap.data() } as AglynOrgMember)\n : null\n await syncMemberOrgReach(db, orgId, uid, member)\n const meta = await readHostMeta(db, hostIds)\n const ops = hostIds.map((hostId) => (batch: FirebaseFirestore.WriteBatch) => {\n const role = hostRoleFor(member, hostId)\n const ref = membershipRef(db, uid, hostId)\n if (role) batch.set(ref, membershipRow(orgId, meta.get(hostId), role), { merge: true })\n else batch.delete(ref)\n })\n await commitChunked(db, ops)\n}\n\n/**\n * Re-sync ONE host's projection rows across all org members: set a row for\n * every member who can reach it, delete for those who cannot. Call after a\n * host is created, renamed, or its displayName changes.\n */\nexport async function syncHostProjectionForMembers(\n orgId: string,\n hostId: string,\n): Promise<void> {\n const db = firestore()\n const [members, meta] = await Promise.all([\n listMembers(db, orgId),\n readHostMeta(db, [hostId]),\n ])\n const hostMeta = meta.get(hostId)\n const ops = members.map((member) => (batch: FirebaseFirestore.WriteBatch) => {\n const role = hostRoleFor(member, hostId)\n const ref = membershipRef(db, member.$id, hostId)\n if (role) batch.set(ref, membershipRow(orgId, hostMeta, role), { merge: true })\n else batch.delete(ref)\n })\n await commitChunked(db, ops)\n // The staff Sites list's keys, on the site itself. `update`, never a\n // merge-set: a site erased since the read must not be minted back as a\n // phantom carrying only these three keys (the AGL-1765 shape). Outside the\n // batch so a site gone missing cannot fail its members' rows.\n if (hostMeta?.exists) {\n await db\n .collection('hosts')\n .doc(hostId)\n .update(hostSearchFields(hostMeta))\n .catch(() => undefined)\n }\n}\n\n/** Delete every projection row for a member across the org's hosts (member removed). */\nexport async function deleteMemberHostProjections(\n orgId: string,\n uid: string,\n): Promise<void> {\n const db = firestore()\n const hostIds = await orgHostIds(db, orgId)\n await commitChunked(\n db,\n hostIds.map((hostId) => (batch: FirebaseFirestore.WriteBatch) => {\n batch.delete(membershipRef(db, uid, hostId))\n }),\n )\n}\n\n/** Delete every member's projection row for one host (host erased). */\nexport async function deleteHostProjectionForAllMembers(\n orgId: string,\n hostId: string,\n): Promise<void> {\n const db = firestore()\n const members = await listMembers(db, orgId)\n await commitChunked(\n db,\n members.map((member) => (batch: FirebaseFirestore.WriteBatch) => {\n batch.delete(membershipRef(db, member.$id, hostId))\n }),\n )\n}\n"],"names":["hostRoleFor","isOrgWideMember","nameSearchKey","nameSearchTokens","FieldValue","firebaseAdmin","firestore","app","CHUNK","orgHostIds","db","orgId","org","collection","doc","get","data","Object","keys","hosts","readHostMeta","hostIds","length","Map","snaps","getAll","map","id","snap","displayName","subdomain","favicon","cname","createdAt","exists","listMembers","docs","d","$id","membershipRef","uid","hostId","WORD_SEPARATOR","wordTails","word","chars","tails","at","test","push","slice","join","hostMembershipSearchTokens","meta","words","value","flatMap","key","split","hasCustomDomain","trim","hostSearchFields","nameLower","searchTokens","isTimestamp","toMillis","membershipRow","role","delete","updatedAt","serverTimestamp","commitChunked","ops","i","batch","op","commit","syncMemberOrgReach","member","ref","orgWide","set","merge","syncMemberHostProjections","memberSnap","Promise","all","syncHostProjectionForMembers","members","hostMeta","update","catch","undefined","deleteMemberHostProjections","deleteHostProjectionForAllMembers"],"mappings":"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"}