@aglyn/aglyn 1.0.0-beta.176 → 1.0.0-beta.177

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.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/aglyn/src/lib/foundation/definitions/organization.types.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 * Multi-tenant organizations (AGL-233, docs/MULTI_TENANT_FIRESTORE.md):\n * the org is the tenant boundary — one subscription, one workspace\n * subdomain, one isolation subtree. Billing fields mirror `AglynOrgBilling`\n * so the plan/entitlement resolvers work on either doc during the\n * transition; org-keyed billing lands with AGL-237.\n */\n\nimport type { ITimestamp } from '@aglyn/shared-util-timestamp'\nimport type {\n OrgCrmInbound,\n OrgCrmSettings,\n OrgEntitlements,\n OrgPlan,\n OrgSeatAddons,\n OrgSubscription,\n} from './org-billing.types'\nimport type {\n AglynDocument,\n HostUid,\n OrgUid,\n ScopeToken,\n UserUid,\n} from './platform.types'\n\nexport type { OrgUid } from './platform.types'\n/** Workspace subdomain label: `{slug}.aglyn.com` (Slack-style). */\nexport type OrgSlug = string\n\n/** Org-wide roles, strongest to weakest. */\nexport type OrgRole = 'owner' | 'admin' | 'editor' | 'viewer'\n\n/**\n * Per-host refinement for editor/viewer members (\"3 of 15 sites\").\n *\n * `author` (AGL-2334) is `editor` MINUS publish: it may create and edit\n * every content document on the site, and it may not make any of it live.\n * It exists because the agency guide sells exactly that role — \"a client who\n * may edit content but not publish\" — and until now the narrowest thing we\n * could offer was `viewer`, which cannot edit at all.\n *\n * It is a HOST role rather than a twelfth org permission key deliberately.\n * Publishing is a set of client-direct Firestore writes, so it is enforced\n * in the security rules, and the rules resolve a host request from the\n * `memberRoles` projection with the one `get()` they already do. An org\n * permission key is on the wrong axis: rules cannot evaluate a custom role\n * without a second denormalized projection, and every publish surface would\n * need a server route it does not have.\n *\n * Ordered between `editor` and `viewer` because that is its strength, but\n * NOTHING may treat this union as ordered — `ORG_ROLE_WEIGHT` exists for the\n * org axis and has no counterpart here on purpose. Host roles are compared by\n * membership in a set (`HOST_CONTENT_WRITE_ROLES`, `HOST_PUBLISH_ROLES`), and\n * an author is not \"a weaker editor\" in a way any single number can express.\n */\nexport type HostAccessRole = 'admin' | 'editor' | 'author' | 'viewer'\n\n/**\n * A per-site permission key a collaborator can carry (AGL-2927, AGL-2984).\n * The host role decides what a collaborator may do to the SITE; these keys\n * decide what else they may do on it. Each is a catalog key a plugin\n * declares with host-role defaults, the same dotted key the org catalog\n * names, so one label serves both rosters.\n */\nexport type HostPermissionKey = string\n\n/**\n * Every host role, as a value — for `where('memberRoles.{uid}', 'in', …)`.\n *\n * `/hosts/{hostId}` is gated per document on `memberRoles.{uid}`, and\n * Firestore refuses to run a LIST that could return a denied document. So\n * every client query over `hosts` must carry this filter, and it must name\n * ALL the roles: a role missing from the array is a site the member owns and\n * cannot see, with no error to explain it (AGL-1145).\n *\n * Built from a `Record` rather than written as an array so that adding a role\n * to the union above fails to COMPILE here. A plain `HostAccessRole[]` would\n * happily stay short, which is the silent half of the bug.\n */\nconst HOST_ACCESS_ROLE_KEYS: Record<HostAccessRole, true> = {\n admin: true,\n editor: true,\n author: true,\n viewer: true,\n}\nexport const HOST_ACCESS_ROLES = Object.keys(\n HOST_ACCESS_ROLE_KEYS,\n) as HostAccessRole[]\n\nexport interface AglynOrganization extends AglynDocument {\n $id: OrgUid\n name?: string\n slug?: OrgSlug\n /** Current owner; ownership can move without re-keying anything. */\n ownerUid?: UserUid\n /**\n * Who CREATED the workspace — stamped once inside `createOrganization`'s\n * transaction and mutated by nothing, `transferOrgOwnership` included\n * (AGL-2265).\n *\n * `ownerUid` moves; this does not, and the difference is the whole point.\n * The free-workspace ceiling counts the UNION of the two, so handing a\n * workspace to an alt account, creating a fourth and taking the first one\n * back is not a way past the limit. Denied to client writes in the rules\n * for the same reason — a client that could clear its own attribution could\n * mint free workspaces without limit.\n *\n * Absent on every org created before AGL-2265 shipped, and every reader\n * must tolerate that: those are counted by `ownerUid` exactly as they\n * always were.\n */\n createdByUid?: UserUid\n /** Directory of the org's hosts (mirrors AglynOrgBilling.hosts). */\n hosts?: Record<HostUid, true>\n\n // Billing (mirrors AglynOrgBilling; source of truth moves here with AGL-237)\n plan?: OrgPlan\n entitlements?: OrgEntitlements\n /**\n * Per-org plugin switchboard (AGL-416): ids of plugins the workspace\n * loads (see plugin-manager/enabled-plugins). Absent = all first-party\n * plugins; always-on ids (base components) and the ids on for every\n * workspace (AI) are unioned in regardless — a site switches those off for\n * itself instead.\n */\n enabledPlugins?: string[]\n seatAddons?: OrgSeatAddons\n stripeCustomerId?: string\n subscription?: OrgSubscription\n suspendedAt?: ITimestamp | null\n /** Staff-internal rationale (AGL-202). NEVER shown to the customer. */\n suspendedReason?: string\n /**\n * Lockdown extensions (AGL-1501) on the shipped AGL-202 carrier — the org\n * scope of the panic button. `suspendedReasonCode` is the enum the notice\n * copy switches on (`security`/`billing`/`maintenance`/`manual`; absent =\n * `manual`), `suspendedMessage` is the CUSTOMER-FACING notice body (unlike\n * `suspendedReason`), and `suspendedUntilMs` is an optional expiry — once\n * it passes, the suspension is inactive with no write needed (maintenance\n * windows end on their own). Plain epoch ms, not a Timestamp, so every\n * cache serialization reads it back unchanged. All written only by\n * /api/admin/lockdown; normalized by `app-utils/lockdown.ts`.\n */\n suspendedReasonCode?: string\n suspendedMessage?: string\n suspendedUntilMs?: number\n erasureRequestedAt?: ITimestamp | null\n /**\n * Scope applied to newly created datasets and media when nobody chooses\n * (AGL-1048). `'org'` — the default and today's behavior — shares them\n * with every site. `'host'` starts them private to the site they were\n * created in, which is what an agency running client sites wants: safe\n * by default rather than safe by discipline.\n *\n * Only meaningful when there IS a site in context. Created from the org\n * Media or Data page there is no host to scope to, so those stay `'org'`\n * either way.\n */\n defaultResourceScope?: 'org' | 'host'\n /** The CRM's organization-wide settings (AGL-2613) — see `OrgCrmSettings`. */\n crm?: OrgCrmSettings\n /** The workspace's email capture address (AGL-2657) — see `OrgCrmInbound`. */\n crmInbound?: OrgCrmInbound\n}\n\n/**\n * `orgs/{orgId}/members/{uid}` — THE authorization doc: rules resolve a\n * request with this single read. Owner/admin span every host; editor and\n * viewer see `hostAccess` (or `allHosts`).\n */\nexport interface AglynOrgMember extends AglynDocument {\n $id: UserUid\n role?: OrgRole\n /**\n * Custom role reference (AGL-243): id of an `orgs/{orgId}/roles` doc\n * whose permission map overrides the org role's defaults.\n */\n roleId?: string\n /** Per-member permission overrides (AGL-243); win over every layer. */\n permissions?: Record<string, boolean>\n /** Org-wide host access shortcut; otherwise `hostAccess` decides. */\n allHosts?: boolean\n hostAccess?: Record<HostUid, HostAccessRole>\n /**\n * Per-site permission overrides for a COLLABORATOR (AGL-2927), keyed by\n * the host the grant is for. Absent keys resolve by the host role's\n * default, so a document written before this field existed reads the same\n * as one written after it. Org-wide members are never consulted here:\n * their org role, custom role and `permissions` map decide.\n */\n hostPermissions?: Record<HostUid, Partial<Record<HostPermissionKey, boolean>>>\n /**\n * Denormalized reach as scope tokens (AGL-1038), so rules can intersect\n * it with a resource's `visibleTo` — they cannot derive it from\n * `hostAccess` because the rules language has no `.map()`. Written only\n * by `syncOrgAuthProjections`; never edit it by hand.\n */\n scopeTokens?: ScopeToken[]\n /**\n * Denormalized THREE-LAYER permission verdict, so rules can honor a custom\n * role they cannot resolve.\n *\n * `roleId` points at another document and `permissions` is only the top\n * layer, so a rule reading either alone answers a different question from\n * the server — and reproducing the precedence in CEL needs a second\n * cross-document get() plus correct handling of a dangling id. This is\n * `resolveOrgPermissions(member, customRole)`, already applied.\n *\n * ⚠️ ABSENT IS NOT EMPTY, and the rules must never treat it as either\n * \"everything allowed\" or \"nothing allowed\". Documents predating this\n * field, and any written by a path that bypasses\n * `syncOrgAuthProjections`, carry no map at all; the rules fall back to\n * the layers they CAN read — the role and the per-member overrides — which\n * is exactly the verdict they gave before this field existed. Written only\n * by `syncOrgAuthProjections` and by `createOrganization`'s inline stamp;\n * never edit it by hand.\n */\n resolvedPermissions?: Record<string, boolean>\n /** Denormalized for member lists without N user lookups. */\n displayName?: string\n email?: string\n invitedBy?: UserUid\n joinedAt?: ITimestamp\n /**\n * Denormalized org suspension flag (AGL-210) so host writes stay a\n * single rules read; maintained by the staff suspension API.\n */\n orgSuspended?: boolean\n}\n\n/** `orgs/{orgId}/invites/{inviteId}` — pending email invites. */\nexport interface AglynOrgInvite extends AglynDocument {\n $id: string\n email?: string\n role?: OrgRole\n allHosts?: boolean\n hostAccess?: Record<HostUid, HostAccessRole>\n invitedBy?: UserUid\n createdAt?: ITimestamp\n acceptedAt?: ITimestamp | null\n acceptedBy?: UserUid\n}\n\n/**\n * `orgSlugs/{slug}` — transactional uniqueness reservation; created and\n * deleted only by the org APIs (Admin SDK), publicly readable so the\n * console can resolve a workspace subdomain client-side.\n */\nexport interface OrgSlugReservation {\n orgId: OrgUid\n /**\n * Set when the previous holder renamed away — the tombstone that keeps old\n * workspace URLs redirecting until somebody else wants the name.\n */\n movedTo?: string\n /**\n * Epoch millis at which a PENDING reservation lapses (AGL-2585).\n *\n * Present only on a workspace created by an owner whose email was not yet\n * verified, which is every password signup: the address is held for them,\n * not granted to them, and the hold ends unless they confirm the address.\n * Absent means granted, and a grant never expires — so every workspace made\n * by a verified owner, and every one that predates this field, is outside\n * the rule entirely.\n *\n * Cleared by `reap-unverified-orgs` once the owner verifies. Public, like\n * the rest of this document, which is why nothing identifying the owner is\n * written beside it.\n */\n reservedUntil?: number\n}\n\n/**\n * `hostIndex/{hostId}` — server-written host → org resolver so the tenant\n * renderer and middleware find a host's org without scanning.\n */\nexport interface HostIndexEntry {\n orgId: OrgUid\n subdomain?: string\n}\n\n/**\n * `users/{uid}/orgs/{orgId}` — reverse index for \"my organizations\",\n * maintained transactionally with the member doc by the membership API.\n */\nexport interface UserOrgMembership extends AglynDocument {\n $id: OrgUid\n role?: OrgRole\n orgName?: string\n slug?: OrgSlug\n /**\n * Mirror of `isOrgWideMember(orgs/{orgId}/members/{uid})` (AGL-1032): does\n * this membership reach the whole org, or only a list of sites?\n *\n * `role` alone cannot answer it — `grantHostAccess` writes `role: 'viewer'`\n * here for a site collaborator, exactly what a genuine org-wide viewer\n * carries, so the console could not tell them apart without a second read\n * of the member doc on every org route. Denormalized because the console\n * navigation guard has to be synchronous: an async answer flashes the org\n * chrome before hiding it.\n *\n * ABSENT means org-wide. Rows predating the mirror carry no flag, and a\n * missing field must never lock a real member out of their own workspace\n * (the same legacy shape `isOrgWideMember` handles). Only an explicit\n * `false` scopes the console — see `isOrgWideMembership`.\n *\n * Navigation only. The Firestore rules are the access boundary (AGL-1026);\n * a stale mirror at worst shows a page whose reads then come back empty.\n */\n orgWide?: boolean\n}\n\n/**\n * `users/{uid}/hostMemberships/{hostId}` — reverse index of the sites a user\n * can reach, mirroring `hosts/{hostId}.memberRoles` (AGL-844). Denormalizes the\n * host name so the site switcher and subdomain→id routing query a user's own\n * sites, ordered and name-prefix-searched, without scanning the `hosts`\n * collection. Admin-SDK-maintained beside `memberRoles`; a best-effort\n * convenience index, never an authorization source (the rules still gate host\n * reads on `memberRoles`). NOT named `hosts` — that would collide with the\n * top-level `hosts` collection for collection-group rules/indexes.\n */\nexport interface UserHostMembership extends AglynDocument {\n $id: HostUid\n orgId?: OrgUid\n subdomain?: string\n displayName?: string\n nameLower?: string\n /**\n * Mirror of the host's `seo.favicon` (AGL-1071). The site switcher renders\n * from this projection rather than the host doc, so without the mirror it\n * showed the generic glyph for every site — the favicon feature worked in\n * the sites list and nowhere else. Absent when the site has none; writers\n * must DELETE it on clear rather than omit it, since the rows are written\n * with `{ merge: true }` and an omitted field would keep the old icon.\n */\n favicon?: string\n role?: HostAccessRole\n updatedAt?: ITimestamp\n}\n"],"names":["HOST_ACCESS_ROLE_KEYS","admin","editor","author","viewer","HOST_ACCESS_ROLES","Object","keys"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;CAMC,GA4DD;;;;;;;;;;;;CAYC,GACD,MAAMA,wBAAsD;IAC1DC,OAAO;IACPC,QAAQ;IACRC,QAAQ;IACRC,QAAQ;AACV;AACA,OAAO,MAAMC,oBAAoBC,OAAOC,IAAI,CAC1CP,uBACmB"}
1
+ {"version":3,"sources":["../../../../../../../libs/aglyn/src/lib/foundation/definitions/organization.types.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 * Multi-tenant organizations (AGL-233, docs/MULTI_TENANT_FIRESTORE.md):\n * the org is the tenant boundary — one subscription, one workspace\n * subdomain, one isolation subtree. Billing fields mirror `AglynOrgBilling`\n * so the plan/entitlement resolvers work on either doc during the\n * transition; org-keyed billing lands with AGL-237.\n */\n\nimport type { ITimestamp } from '@aglyn/shared-util-timestamp'\nimport type { AccountAcquisition } from '../../app-utils/account-acquisition'\nimport type {\n OrgCrmInbound,\n OrgCrmSettings,\n OrgEntitlements,\n OrgPlan,\n OrgSeatAddons,\n OrgSubscription,\n} from './org-billing.types'\nimport type {\n AglynDocument,\n HostUid,\n OrgUid,\n ScopeToken,\n UserUid,\n} from './platform.types'\n\nexport type { OrgUid } from './platform.types'\n/** Workspace subdomain label: `{slug}.aglyn.com` (Slack-style). */\nexport type OrgSlug = string\n\n/** Org-wide roles, strongest to weakest. */\nexport type OrgRole = 'owner' | 'admin' | 'editor' | 'viewer'\n\n/**\n * Per-host refinement for editor/viewer members (\"3 of 15 sites\").\n *\n * `author` (AGL-2334) is `editor` MINUS publish: it may create and edit\n * every content document on the site, and it may not make any of it live.\n * It exists because the agency guide sells exactly that role — \"a client who\n * may edit content but not publish\" — and until now the narrowest thing we\n * could offer was `viewer`, which cannot edit at all.\n *\n * It is a HOST role rather than a twelfth org permission key deliberately.\n * Publishing is a set of client-direct Firestore writes, so it is enforced\n * in the security rules, and the rules resolve a host request from the\n * `memberRoles` projection with the one `get()` they already do. An org\n * permission key is on the wrong axis: rules cannot evaluate a custom role\n * without a second denormalized projection, and every publish surface would\n * need a server route it does not have.\n *\n * Ordered between `editor` and `viewer` because that is its strength, but\n * NOTHING may treat this union as ordered — `ORG_ROLE_WEIGHT` exists for the\n * org axis and has no counterpart here on purpose. Host roles are compared by\n * membership in a set (`HOST_CONTENT_WRITE_ROLES`, `HOST_PUBLISH_ROLES`), and\n * an author is not \"a weaker editor\" in a way any single number can express.\n */\nexport type HostAccessRole = 'admin' | 'editor' | 'author' | 'viewer'\n\n/**\n * A per-site permission key a collaborator can carry (AGL-2927, AGL-2984).\n * The host role decides what a collaborator may do to the SITE; these keys\n * decide what else they may do on it. Each is a catalog key a plugin\n * declares with host-role defaults, the same dotted key the org catalog\n * names, so one label serves both rosters.\n */\nexport type HostPermissionKey = string\n\n/**\n * Every host role, as a value — for `where('memberRoles.{uid}', 'in', …)`.\n *\n * `/hosts/{hostId}` is gated per document on `memberRoles.{uid}`, and\n * Firestore refuses to run a LIST that could return a denied document. So\n * every client query over `hosts` must carry this filter, and it must name\n * ALL the roles: a role missing from the array is a site the member owns and\n * cannot see, with no error to explain it (AGL-1145).\n *\n * Built from a `Record` rather than written as an array so that adding a role\n * to the union above fails to COMPILE here. A plain `HostAccessRole[]` would\n * happily stay short, which is the silent half of the bug.\n */\nconst HOST_ACCESS_ROLE_KEYS: Record<HostAccessRole, true> = {\n admin: true,\n editor: true,\n author: true,\n viewer: true,\n}\nexport const HOST_ACCESS_ROLES = Object.keys(\n HOST_ACCESS_ROLE_KEYS,\n) as HostAccessRole[]\n\nexport interface AglynOrganization extends AglynDocument {\n $id: OrgUid\n name?: string\n slug?: OrgSlug\n /** Current owner; ownership can move without re-keying anything. */\n ownerUid?: UserUid\n /**\n * Who CREATED the workspace — stamped once inside `createOrganization`'s\n * transaction and mutated by nothing, `transferOrgOwnership` included\n * (AGL-2265).\n *\n * `ownerUid` moves; this does not, and the difference is the whole point.\n * The free-workspace ceiling counts the UNION of the two, so handing a\n * workspace to an alt account, creating a fourth and taking the first one\n * back is not a way past the limit. Denied to client writes in the rules\n * for the same reason — a client that could clear its own attribution could\n * mint free workspaces without limit.\n *\n * Absent on every org created before AGL-2265 shipped, and every reader\n * must tolerate that: those are counted by `ownerUid` exactly as they\n * always were.\n */\n createdByUid?: UserUid\n /**\n * Where the workspace came from (AGL-3289): its creator's acquisition\n * record, copied by `createOrganization` at birth and naming the account it\n * was copied from. Written by nothing else, and denied to client writes on\n * every rules branch, for the reason `createdByUid` is: it is the\n * platform's record about the workspace, not the workspace's about itself.\n */\n acquisition?: AccountAcquisition\n /** Directory of the org's hosts (mirrors AglynOrgBilling.hosts). */\n hosts?: Record<HostUid, true>\n\n // Billing (mirrors AglynOrgBilling; source of truth moves here with AGL-237)\n plan?: OrgPlan\n entitlements?: OrgEntitlements\n /**\n * Per-org plugin switchboard (AGL-416): ids of plugins the workspace\n * loads (see plugin-manager/enabled-plugins). Absent = all first-party\n * plugins; always-on ids (base components) and the ids on for every\n * workspace (AI) are unioned in regardless — a site switches those off for\n * itself instead.\n */\n enabledPlugins?: string[]\n seatAddons?: OrgSeatAddons\n stripeCustomerId?: string\n subscription?: OrgSubscription\n suspendedAt?: ITimestamp | null\n /** Staff-internal rationale (AGL-202). NEVER shown to the customer. */\n suspendedReason?: string\n /**\n * Lockdown extensions (AGL-1501) on the shipped AGL-202 carrier — the org\n * scope of the panic button. `suspendedReasonCode` is the enum the notice\n * copy switches on (`security`/`billing`/`maintenance`/`manual`; absent =\n * `manual`), `suspendedMessage` is the CUSTOMER-FACING notice body (unlike\n * `suspendedReason`), and `suspendedUntilMs` is an optional expiry — once\n * it passes, the suspension is inactive with no write needed (maintenance\n * windows end on their own). Plain epoch ms, not a Timestamp, so every\n * cache serialization reads it back unchanged. All written only by\n * /api/admin/lockdown; normalized by `app-utils/lockdown.ts`.\n */\n suspendedReasonCode?: string\n suspendedMessage?: string\n suspendedUntilMs?: number\n erasureRequestedAt?: ITimestamp | null\n /**\n * Scope applied to newly created datasets and media when nobody chooses\n * (AGL-1048). `'org'` — the default and today's behavior — shares them\n * with every site. `'host'` starts them private to the site they were\n * created in, which is what an agency running client sites wants: safe\n * by default rather than safe by discipline.\n *\n * Only meaningful when there IS a site in context. Created from the org\n * Media or Data page there is no host to scope to, so those stay `'org'`\n * either way.\n */\n defaultResourceScope?: 'org' | 'host'\n /** The CRM's organization-wide settings (AGL-2613) — see `OrgCrmSettings`. */\n crm?: OrgCrmSettings\n /** The workspace's email capture address (AGL-2657) — see `OrgCrmInbound`. */\n crmInbound?: OrgCrmInbound\n}\n\n/**\n * `orgs/{orgId}/members/{uid}` — THE authorization doc: rules resolve a\n * request with this single read. Owner/admin span every host; editor and\n * viewer see `hostAccess` (or `allHosts`).\n */\nexport interface AglynOrgMember extends AglynDocument {\n $id: UserUid\n role?: OrgRole\n /**\n * Custom role reference (AGL-243): id of an `orgs/{orgId}/roles` doc\n * whose permission map overrides the org role's defaults.\n */\n roleId?: string\n /** Per-member permission overrides (AGL-243); win over every layer. */\n permissions?: Record<string, boolean>\n /** Org-wide host access shortcut; otherwise `hostAccess` decides. */\n allHosts?: boolean\n hostAccess?: Record<HostUid, HostAccessRole>\n /**\n * Per-site permission overrides for a COLLABORATOR (AGL-2927), keyed by\n * the host the grant is for. Absent keys resolve by the host role's\n * default, so a document written before this field existed reads the same\n * as one written after it. Org-wide members are never consulted here:\n * their org role, custom role and `permissions` map decide.\n */\n hostPermissions?: Record<HostUid, Partial<Record<HostPermissionKey, boolean>>>\n /**\n * Denormalized reach as scope tokens (AGL-1038), so rules can intersect\n * it with a resource's `visibleTo` — they cannot derive it from\n * `hostAccess` because the rules language has no `.map()`. Written only\n * by `syncOrgAuthProjections`; never edit it by hand.\n */\n scopeTokens?: ScopeToken[]\n /**\n * Denormalized THREE-LAYER permission verdict, so rules can honor a custom\n * role they cannot resolve.\n *\n * `roleId` points at another document and `permissions` is only the top\n * layer, so a rule reading either alone answers a different question from\n * the server — and reproducing the precedence in CEL needs a second\n * cross-document get() plus correct handling of a dangling id. This is\n * `resolveOrgPermissions(member, customRole)`, already applied.\n *\n * ⚠️ ABSENT IS NOT EMPTY, and the rules must never treat it as either\n * \"everything allowed\" or \"nothing allowed\". Documents predating this\n * field, and any written by a path that bypasses\n * `syncOrgAuthProjections`, carry no map at all; the rules fall back to\n * the layers they CAN read — the role and the per-member overrides — which\n * is exactly the verdict they gave before this field existed. Written only\n * by `syncOrgAuthProjections` and by `createOrganization`'s inline stamp;\n * never edit it by hand.\n */\n resolvedPermissions?: Record<string, boolean>\n /** Denormalized for member lists without N user lookups. */\n displayName?: string\n email?: string\n invitedBy?: UserUid\n joinedAt?: ITimestamp\n /**\n * Denormalized org suspension flag (AGL-210) so host writes stay a\n * single rules read; maintained by the staff suspension API.\n */\n orgSuspended?: boolean\n}\n\n/** `orgs/{orgId}/invites/{inviteId}` — pending email invites. */\nexport interface AglynOrgInvite extends AglynDocument {\n $id: string\n email?: string\n role?: OrgRole\n allHosts?: boolean\n hostAccess?: Record<HostUid, HostAccessRole>\n invitedBy?: UserUid\n createdAt?: ITimestamp\n acceptedAt?: ITimestamp | null\n acceptedBy?: UserUid\n}\n\n/**\n * `orgSlugs/{slug}` — transactional uniqueness reservation; created and\n * deleted only by the org APIs (Admin SDK), publicly readable so the\n * console can resolve a workspace subdomain client-side.\n */\nexport interface OrgSlugReservation {\n orgId: OrgUid\n /**\n * Set when the previous holder renamed away — the tombstone that keeps old\n * workspace URLs redirecting until somebody else wants the name.\n */\n movedTo?: string\n /**\n * Epoch millis at which a PENDING reservation lapses (AGL-2585).\n *\n * Present only on a workspace created by an owner whose email was not yet\n * verified, which is every password signup: the address is held for them,\n * not granted to them, and the hold ends unless they confirm the address.\n * Absent means granted, and a grant never expires — so every workspace made\n * by a verified owner, and every one that predates this field, is outside\n * the rule entirely.\n *\n * Cleared by `reap-unverified-orgs` once the owner verifies. Public, like\n * the rest of this document, which is why nothing identifying the owner is\n * written beside it.\n */\n reservedUntil?: number\n}\n\n/**\n * `hostIndex/{hostId}` — server-written host → org resolver so the tenant\n * renderer and middleware find a host's org without scanning.\n */\nexport interface HostIndexEntry {\n orgId: OrgUid\n subdomain?: string\n}\n\n/**\n * `users/{uid}/orgs/{orgId}` — reverse index for \"my organizations\",\n * maintained transactionally with the member doc by the membership API.\n */\nexport interface UserOrgMembership extends AglynDocument {\n $id: OrgUid\n role?: OrgRole\n orgName?: string\n slug?: OrgSlug\n /**\n * Mirror of `isOrgWideMember(orgs/{orgId}/members/{uid})` (AGL-1032): does\n * this membership reach the whole org, or only a list of sites?\n *\n * `role` alone cannot answer it — `grantHostAccess` writes `role: 'viewer'`\n * here for a site collaborator, exactly what a genuine org-wide viewer\n * carries, so the console could not tell them apart without a second read\n * of the member doc on every org route. Denormalized because the console\n * navigation guard has to be synchronous: an async answer flashes the org\n * chrome before hiding it.\n *\n * ABSENT means org-wide. Rows predating the mirror carry no flag, and a\n * missing field must never lock a real member out of their own workspace\n * (the same legacy shape `isOrgWideMember` handles). Only an explicit\n * `false` scopes the console — see `isOrgWideMembership`.\n *\n * Navigation only. The Firestore rules are the access boundary (AGL-1026);\n * a stale mirror at worst shows a page whose reads then come back empty.\n */\n orgWide?: boolean\n}\n\n/**\n * `users/{uid}/hostMemberships/{hostId}` — reverse index of the sites a user\n * can reach, mirroring `hosts/{hostId}.memberRoles` (AGL-844). Denormalizes the\n * host name so the site switcher and subdomain→id routing query a user's own\n * sites, ordered and name-prefix-searched, without scanning the `hosts`\n * collection. Admin-SDK-maintained beside `memberRoles`; a best-effort\n * convenience index, never an authorization source (the rules still gate host\n * reads on `memberRoles`). NOT named `hosts` — that would collide with the\n * top-level `hosts` collection for collection-group rules/indexes.\n */\nexport interface UserHostMembership extends AglynDocument {\n $id: HostUid\n orgId?: OrgUid\n subdomain?: string\n displayName?: string\n nameLower?: string\n /**\n * Mirror of the host's `seo.favicon` (AGL-1071). The site switcher renders\n * from this projection rather than the host doc, so without the mirror it\n * showed the generic glyph for every site — the favicon feature worked in\n * the sites list and nowhere else. Absent when the site has none; writers\n * must DELETE it on clear rather than omit it, since the rows are written\n * with `{ merge: true }` and an omitted field would keep the old icon.\n */\n favicon?: string\n role?: HostAccessRole\n updatedAt?: ITimestamp\n}\n"],"names":["HOST_ACCESS_ROLE_KEYS","admin","editor","author","viewer","HOST_ACCESS_ROLES","Object","keys"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;CAMC,GA6DD;;;;;;;;;;;;CAYC,GACD,MAAMA,wBAAsD;IAC1DC,OAAO;IACPC,QAAQ;IACRC,QAAQ;IACRC,QAAQ;AACV;AACA,OAAO,MAAMC,oBAAoBC,OAAOC,IAAI,CAC1CP,uBACmB"}
@@ -0,0 +1,75 @@
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
+ /** Who to look for, and where. */
18
+ export interface PluginPersonMatchRequest {
19
+ /** The workspace whose records are searched. */
20
+ orgId: string;
21
+ /** Its slug, so a matcher can link to what it found. */
22
+ orgSlug: string | null;
23
+ /** The account's address, normalized; null when it has none. */
24
+ email: string | null;
25
+ /** The account's name, for the name half; null when it has none. */
26
+ name: string | null;
27
+ /**
28
+ * When the account was created. A record the account's own sign-up made at
29
+ * or after this moment is the account, not somebody already known.
30
+ */
31
+ accountCreatedAtMs: number | null;
32
+ }
33
+ /** One record a matcher found. */
34
+ export interface PluginPersonMatch {
35
+ /** The record's kind, in the plugin's own words. */
36
+ kind: string;
37
+ id: string;
38
+ /** What a reader recognizes the record by — a name, else its address. */
39
+ label: string;
40
+ email: string | null;
41
+ /** `email` is certain; `name` is a resemblance, shown as "possible". */
42
+ basis: 'email' | 'name';
43
+ /** When the workspace first knew this record, when it can say. */
44
+ firstSeenAtMs: number | null;
45
+ /** How the workspace came to know them, in the plugin's words. */
46
+ sources: string[];
47
+ /** A console path to the record, when the plugin publishes one. */
48
+ href: string | null;
49
+ }
50
+ export type PluginPersonMatcher = (request: PluginPersonMatchRequest) => Promise<PluginPersonMatch[]>;
51
+ /**
52
+ * Registers a plugin's matcher. Owner = the loader's marker inside a register
53
+ * fn, else `options.pluginId`; a matcher with neither throws. One matcher per
54
+ * plugin: registering again replaces the earlier one in place.
55
+ */
56
+ export declare function registerPluginPersonMatcher(matcher: PluginPersonMatcher, options?: {
57
+ pluginId?: string;
58
+ }): void;
59
+ /** The plugins with a matcher registered, in registration order. */
60
+ export declare function listPluginPersonMatchers(): string[];
61
+ /** Test seam — forget every matcher. */
62
+ export declare function resetPluginPersonMatchers(): void;
63
+ /** What every matcher answered, together. */
64
+ export interface PluginPersonMatchReport {
65
+ /** Every match, tagged with the plugin that found it; certain ones first. */
66
+ matches: Array<PluginPersonMatch & {
67
+ pluginId: string;
68
+ }>;
69
+ /** Plugins whose matcher threw — their absence from `matches` measures nothing. */
70
+ failed: string[];
71
+ /** Plugins that were asked. None means nothing on this install can answer. */
72
+ asked: string[];
73
+ }
74
+ /** Runs every matcher. Never throws. */
75
+ export declare function runPluginPersonMatchers(request: PluginPersonMatchRequest): Promise<PluginPersonMatchReport>;
@@ -0,0 +1,103 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /**
18
+ * A plugin's answer to "did this workspace already know this person?"
19
+ * (AGL-3289).
20
+ *
21
+ * The platform asks when a staff member reads where an account came from: a
22
+ * sign-up whose address a sales workspace was already working, or whose name
23
+ * matches somebody on a list, is the answer to "is this the person we have
24
+ * been writing to — on a personal address?". The platform keeps no people of
25
+ * its own, so it cannot answer; any plugin that keeps records about people
26
+ * can, and registers a matcher here. The platform runs every matcher and
27
+ * shows what comes back.
28
+ *
29
+ * Two kinds of answer, and the distinction is the point of the seam:
30
+ *
31
+ * - **`email`** — the record carries the account's address. Certain.
32
+ * - **`name`** — the record's name resembles the account's. A GUESS, which the
33
+ * staff card labels "possible" and never presents as a fact.
34
+ *
35
+ * A matcher leaves out the record the account's own sign-up created: a
36
+ * platform that files every new account into its sales workspace would
37
+ * otherwise answer "yes, we know them" for everybody, which says nothing.
38
+ * What is worth showing is a record that existed BEFORE the account did.
39
+ *
40
+ * Matchers run in registration order and are ISOLATED: a throw is logged
41
+ * against its plugin and reported as that plugin having failed, so a card can
42
+ * say "could not check" rather than showing an absence it did not measure.
43
+ * Nothing here writes; every matcher is a read.
44
+ *
45
+ * Registered from a plugin's server declarations, so the matcher is in place
46
+ * in a process that never loaded the plugin's API surface.
47
+ */ import { getRegisteringPluginId } from "../app-utils/registering-plugin.js";
48
+ const registrations = [];
49
+ /**
50
+ * Registers a plugin's matcher. Owner = the loader's marker inside a register
51
+ * fn, else `options.pluginId`; a matcher with neither throws. One matcher per
52
+ * plugin: registering again replaces the earlier one in place.
53
+ */ export function registerPluginPersonMatcher(matcher, options) {
54
+ var _ref, _getRegisteringPluginId;
55
+ const pluginId = ((_ref = (_getRegisteringPluginId = getRegisteringPluginId()) != null ? _getRegisteringPluginId : options == null ? void 0 : options.pluginId) != null ? _ref : '').trim();
56
+ if (!pluginId) {
57
+ throw new Error('a plugin person matcher was registered with no owner: pass { pluginId } ' + 'when registering outside a plugin register fn');
58
+ }
59
+ const index = registrations.findIndex((entry)=>entry.pluginId === pluginId);
60
+ if (index >= 0) registrations[index] = {
61
+ pluginId,
62
+ matcher
63
+ };
64
+ else registrations.push({
65
+ pluginId,
66
+ matcher
67
+ });
68
+ }
69
+ /** The plugins with a matcher registered, in registration order. */ export function listPluginPersonMatchers() {
70
+ return registrations.map((entry)=>entry.pluginId);
71
+ }
72
+ /** Test seam — forget every matcher. */ export function resetPluginPersonMatchers() {
73
+ registrations.length = 0;
74
+ }
75
+ /** Runs every matcher. Never throws. */ export async function runPluginPersonMatchers(request) {
76
+ const report = {
77
+ matches: [],
78
+ failed: [],
79
+ asked: []
80
+ };
81
+ for (const { pluginId, matcher } of [
82
+ ...registrations
83
+ ]){
84
+ report.asked.push(pluginId);
85
+ try {
86
+ const found = await matcher(_extends({}, request));
87
+ for (const match of Array.isArray(found) ? found : []){
88
+ report.matches.push(_extends({}, match, {
89
+ pluginId
90
+ }));
91
+ }
92
+ } catch (error) {
93
+ report.failed.push(pluginId);
94
+ // The org, never the person: this line is read by people who were not
95
+ // asked to look the person up.
96
+ console.error(`[plugins] ${pluginId} failed to match a person in org ${request.orgId}`, error);
97
+ }
98
+ }
99
+ report.matches.sort((a, b)=>a.basis === b.basis ? 0 : a.basis === 'email' ? -1 : 1);
100
+ return report;
101
+ }
102
+
103
+ //# sourceMappingURL=plugin-person-matches.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-person-matches.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 * A plugin's answer to \"did this workspace already know this person?\"\n * (AGL-3289).\n *\n * The platform asks when a staff member reads where an account came from: a\n * sign-up whose address a sales workspace was already working, or whose name\n * matches somebody on a list, is the answer to \"is this the person we have\n * been writing to — on a personal address?\". The platform keeps no people of\n * its own, so it cannot answer; any plugin that keeps records about people\n * can, and registers a matcher here. The platform runs every matcher and\n * shows what comes back.\n *\n * Two kinds of answer, and the distinction is the point of the seam:\n *\n * - **`email`** — the record carries the account's address. Certain.\n * - **`name`** — the record's name resembles the account's. A GUESS, which the\n * staff card labels \"possible\" and never presents as a fact.\n *\n * A matcher leaves out the record the account's own sign-up created: a\n * platform that files every new account into its sales workspace would\n * otherwise answer \"yes, we know them\" for everybody, which says nothing.\n * What is worth showing is a record that existed BEFORE the account did.\n *\n * Matchers run in registration order and are ISOLATED: a throw is logged\n * against its plugin and reported as that plugin having failed, so a card can\n * say \"could not check\" rather than showing an absence it did not measure.\n * Nothing here writes; every matcher is a read.\n *\n * Registered from a plugin's server declarations, so the matcher is in place\n * in a process that never loaded the plugin's API surface.\n */\n\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** Who to look for, and where. */\nexport interface PluginPersonMatchRequest {\n /** The workspace whose records are searched. */\n orgId: string\n /** Its slug, so a matcher can link to what it found. */\n orgSlug: string | null\n /** The account's address, normalized; null when it has none. */\n email: string | null\n /** The account's name, for the name half; null when it has none. */\n name: string | null\n /**\n * When the account was created. A record the account's own sign-up made at\n * or after this moment is the account, not somebody already known.\n */\n accountCreatedAtMs: number | null\n}\n\n/** One record a matcher found. */\nexport interface PluginPersonMatch {\n /** The record's kind, in the plugin's own words. */\n kind: string\n id: string\n /** What a reader recognizes the record by — a name, else its address. */\n label: string\n email: string | null\n /** `email` is certain; `name` is a resemblance, shown as \"possible\". */\n basis: 'email' | 'name'\n /** When the workspace first knew this record, when it can say. */\n firstSeenAtMs: number | null\n /** How the workspace came to know them, in the plugin's words. */\n sources: string[]\n /** A console path to the record, when the plugin publishes one. */\n href: string | null\n}\n\nexport type PluginPersonMatcher = (\n request: PluginPersonMatchRequest,\n) => Promise<PluginPersonMatch[]>\n\ninterface Registration {\n pluginId: string\n matcher: PluginPersonMatcher\n}\n\nconst registrations: Registration[] = []\n\n/**\n * Registers a plugin's matcher. Owner = the loader's marker inside a register\n * fn, else `options.pluginId`; a matcher with neither throws. One matcher per\n * plugin: registering again replaces the earlier one in place.\n */\nexport function registerPluginPersonMatcher(\n matcher: PluginPersonMatcher,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n 'a plugin person matcher was registered with no owner: pass { pluginId } ' +\n 'when registering outside a plugin register fn',\n )\n }\n const index = registrations.findIndex((entry) => entry.pluginId === pluginId)\n if (index >= 0) registrations[index] = { pluginId, matcher }\n else registrations.push({ pluginId, matcher })\n}\n\n/** The plugins with a matcher registered, in registration order. */\nexport function listPluginPersonMatchers(): string[] {\n return registrations.map((entry) => entry.pluginId)\n}\n\n/** Test seam — forget every matcher. */\nexport function resetPluginPersonMatchers(): void {\n registrations.length = 0\n}\n\n/** What every matcher answered, together. */\nexport interface PluginPersonMatchReport {\n /** Every match, tagged with the plugin that found it; certain ones first. */\n matches: Array<PluginPersonMatch & { pluginId: string }>\n /** Plugins whose matcher threw — their absence from `matches` measures nothing. */\n failed: string[]\n /** Plugins that were asked. None means nothing on this install can answer. */\n asked: string[]\n}\n\n/** Runs every matcher. Never throws. */\nexport async function runPluginPersonMatchers(\n request: PluginPersonMatchRequest,\n): Promise<PluginPersonMatchReport> {\n const report: PluginPersonMatchReport = { matches: [], failed: [], asked: [] }\n for (const { pluginId, matcher } of [...registrations]) {\n report.asked.push(pluginId)\n try {\n const found = await matcher({ ...request })\n for (const match of Array.isArray(found) ? found : []) {\n report.matches.push({ ...match, pluginId })\n }\n } catch (error) {\n report.failed.push(pluginId)\n // The org, never the person: this line is read by people who were not\n // asked to look the person up.\n console.error(`[plugins] ${pluginId} failed to match a person in org ${request.orgId}`, error)\n }\n }\n report.matches.sort((a, b) =>\n a.basis === b.basis ? 0 : a.basis === 'email' ? -1 : 1,\n )\n return report\n}\n"],"names":["getRegisteringPluginId","registrations","registerPluginPersonMatcher","matcher","options","pluginId","trim","Error","index","findIndex","entry","push","listPluginPersonMatchers","map","resetPluginPersonMatchers","length","runPluginPersonMatchers","request","report","matches","failed","asked","found","match","Array","isArray","error","console","orgId","sort","a","b","basis"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GAED,SAASA,sBAAsB,QAAQ,qCAAiC;AA8CxE,MAAMC,gBAAgC,EAAE;AAExC;;;;CAIC,GACD,OAAO,SAASC,4BACdC,OAA4B,EAC5BC,OAA+B;QAEbJ,MAAAA;IAAlB,MAAMK,WAAW,EAACL,QAAAA,0BAAAA,oCAAAA,0BAA4BI,2BAAAA,QAASC,QAAQ,YAA7CL,OAAiD,IAAIM,IAAI;IAC3E,IAAI,CAACD,UAAU;QACb,MAAM,IAAIE,MACR,6EACE;IAEN;IACA,MAAMC,QAAQP,cAAcQ,SAAS,CAAC,CAACC,QAAUA,MAAML,QAAQ,KAAKA;IACpE,IAAIG,SAAS,GAAGP,aAAa,CAACO,MAAM,GAAG;QAAEH;QAAUF;IAAQ;SACtDF,cAAcU,IAAI,CAAC;QAAEN;QAAUF;IAAQ;AAC9C;AAEA,kEAAkE,GAClE,OAAO,SAASS;IACd,OAAOX,cAAcY,GAAG,CAAC,CAACH,QAAUA,MAAML,QAAQ;AACpD;AAEA,sCAAsC,GACtC,OAAO,SAASS;IACdb,cAAcc,MAAM,GAAG;AACzB;AAYA,sCAAsC,GACtC,OAAO,eAAeC,wBACpBC,OAAiC;IAEjC,MAAMC,SAAkC;QAAEC,SAAS,EAAE;QAAEC,QAAQ,EAAE;QAAEC,OAAO,EAAE;IAAC;IAC7E,KAAK,MAAM,EAAEhB,QAAQ,EAAEF,OAAO,EAAE,IAAI;WAAIF;KAAc,CAAE;QACtDiB,OAAOG,KAAK,CAACV,IAAI,CAACN;QAClB,IAAI;YACF,MAAMiB,QAAQ,MAAMnB,QAAQ,aAAKc;YACjC,KAAK,MAAMM,SAASC,MAAMC,OAAO,CAACH,SAASA,QAAQ,EAAE,CAAE;gBACrDJ,OAAOC,OAAO,CAACR,IAAI,CAAC,aAAKY;oBAAOlB;;YAClC;QACF,EAAE,OAAOqB,OAAO;YACdR,OAAOE,MAAM,CAACT,IAAI,CAACN;YACnB,sEAAsE;YACtE,+BAA+B;YAC/BsB,QAAQD,KAAK,CAAC,CAAC,UAAU,EAAErB,SAAS,iCAAiC,EAAEY,QAAQW,KAAK,EAAE,EAAEF;QAC1F;IACF;IACAR,OAAOC,OAAO,CAACU,IAAI,CAAC,CAACC,GAAGC,IACtBD,EAAEE,KAAK,KAAKD,EAAEC,KAAK,GAAG,IAAIF,EAAEE,KAAK,KAAK,UAAU,CAAC,IAAI;IAEvD,OAAOd;AACT"}