@aglyn/tenant-data-admin 1.0.0-beta.221 → 1.0.0-beta.222

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 (104) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/api-keys.js +3 -3
  3. package/src/lib/server/api-keys.js.map +1 -1
  4. package/src/lib/server/api-v1-resources.d.ts +44 -3
  5. package/src/lib/server/api-v1-resources.js +82 -5
  6. package/src/lib/server/api-v1-resources.js.map +1 -1
  7. package/src/lib/server/collection-live-pages.d.ts +2 -2
  8. package/src/lib/server/collection-live-pages.js +1 -1
  9. package/src/lib/server/collection-live-pages.js.map +1 -1
  10. package/src/lib/server/email-delivery-log.d.ts +17 -73
  11. package/src/lib/server/email-delivery-log.js +44 -222
  12. package/src/lib/server/email-delivery-log.js.map +1 -1
  13. package/src/lib/server/email-flow-gate.js +27 -38
  14. package/src/lib/server/email-flow-gate.js.map +1 -1
  15. package/src/lib/server/erase-person.d.ts +34 -54
  16. package/src/lib/server/erase-person.js +44 -239
  17. package/src/lib/server/erase-person.js.map +1 -1
  18. package/src/lib/server/host-visitor-records.d.ts +12 -4
  19. package/src/lib/server/host-visitor-records.js +20 -10
  20. package/src/lib/server/host-visitor-records.js.map +1 -1
  21. package/src/lib/server/hosted-page-review.js +46 -33
  22. package/src/lib/server/hosted-page-review.js.map +1 -1
  23. package/src/lib/server/link-reputation-review.d.ts +6 -2
  24. package/src/lib/server/link-reputation-review.js +61 -20
  25. package/src/lib/server/link-reputation-review.js.map +1 -1
  26. package/src/lib/server/live-page-drops.d.ts +67 -0
  27. package/src/lib/server/live-page-drops.js +57 -0
  28. package/src/lib/server/live-page-drops.js.map +1 -0
  29. package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
  30. package/src/lib/server/media-bandwidth-cap.js +180 -0
  31. package/src/lib/server/media-bandwidth-cap.js.map +1 -0
  32. package/src/lib/server/media-cdn-icon.d.ts +77 -0
  33. package/src/lib/server/media-cdn-icon.js +140 -0
  34. package/src/lib/server/media-cdn-icon.js.map +1 -0
  35. package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
  36. package/src/lib/server/media-delivery-regeneration.js +216 -0
  37. package/src/lib/server/media-delivery-regeneration.js.map +1 -0
  38. package/src/lib/server/media-delivery.d.ts +2 -0
  39. package/src/lib/server/media-delivery.js +2 -1
  40. package/src/lib/server/media-delivery.js.map +1 -1
  41. package/src/lib/server/media-serve-count.d.ts +50 -0
  42. package/src/lib/server/media-serve-count.js +62 -0
  43. package/src/lib/server/media-serve-count.js.map +1 -0
  44. package/src/lib/server/media-signing.d.ts +13 -2
  45. package/src/lib/server/media-signing.js +12 -8
  46. package/src/lib/server/media-signing.js.map +1 -1
  47. package/src/lib/server/media-tombstone.js +7 -5
  48. package/src/lib/server/media-tombstone.js.map +1 -1
  49. package/src/lib/server/media-variants.d.ts +151 -20
  50. package/src/lib/server/media-variants.js +204 -27
  51. package/src/lib/server/media-variants.js.map +1 -1
  52. package/src/lib/server/organizations.d.ts +102 -2
  53. package/src/lib/server/organizations.js +388 -58
  54. package/src/lib/server/organizations.js.map +1 -1
  55. package/src/lib/server/outbound-send-review.js +5 -4
  56. package/src/lib/server/outbound-send-review.js.map +1 -1
  57. package/src/lib/server/paged-sweeps.d.ts +51 -0
  58. package/src/lib/server/paged-sweeps.js +79 -0
  59. package/src/lib/server/paged-sweeps.js.map +1 -0
  60. package/src/lib/server/platform-marketing-consent.d.ts +0 -49
  61. package/src/lib/server/platform-marketing-consent.js +27 -11
  62. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  63. package/src/lib/server/serve-media-cdn.d.ts +69 -1
  64. package/src/lib/server/serve-media-cdn.js +424 -70
  65. package/src/lib/server/serve-media-cdn.js.map +1 -1
  66. package/src/lib/server/site-icon-render.d.ts +68 -0
  67. package/src/lib/server/site-icon-render.js +157 -0
  68. package/src/lib/server/site-icon-render.js.map +1 -0
  69. package/src/lib/server/staff-seat.d.ts +50 -0
  70. package/src/lib/server/staff-seat.js +118 -0
  71. package/src/lib/server/staff-seat.js.map +1 -0
  72. package/src/lib/server/transfer-export.d.ts +46 -0
  73. package/src/lib/server/transfer-export.js +221 -0
  74. package/src/lib/server/transfer-export.js.map +1 -0
  75. package/src/lib/server/transfer-jobs.d.ts +385 -0
  76. package/src/lib/server/transfer-jobs.js +2368 -0
  77. package/src/lib/server/transfer-jobs.js.map +1 -0
  78. package/src/lib/server/transfer-packages.d.ts +127 -0
  79. package/src/lib/server/transfer-packages.js +1036 -0
  80. package/src/lib/server/transfer-packages.js.map +1 -0
  81. package/src/lib/server/upgrade-proposal.d.ts +97 -0
  82. package/src/lib/server/upgrade-proposal.js +312 -0
  83. package/src/lib/server/upgrade-proposal.js.map +1 -0
  84. package/src/lib/server/upsert-contact.d.ts +17 -8
  85. package/src/lib/server/upsert-contact.js +82 -16
  86. package/src/lib/server/upsert-contact.js.map +1 -1
  87. package/src/lib/server/web-risk.d.ts +48 -9
  88. package/src/lib/server/web-risk.js +281 -143
  89. package/src/lib/server/web-risk.js.map +1 -1
  90. package/src/tenancy.d.ts +3 -1
  91. package/src/tenancy.js +5 -1
  92. package/src/tenancy.js.map +1 -1
  93. package/src/lib/server/campaign-attribution-store.d.ts +0 -55
  94. package/src/lib/server/campaign-attribution-store.js +0 -95
  95. package/src/lib/server/campaign-attribution-store.js.map +0 -1
  96. package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
  97. package/src/lib/server/campaign-conversion-attribution.js +0 -450
  98. package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
  99. package/src/lib/server/dataset-live-pages.d.ts +0 -179
  100. package/src/lib/server/dataset-live-pages.js +0 -421
  101. package/src/lib/server/dataset-live-pages.js.map +0 -1
  102. package/src/lib/server/email-revenue-attribution.d.ts +0 -185
  103. package/src/lib/server/email-revenue-attribution.js +0 -321
  104. package/src/lib/server/email-revenue-attribution.js.map +0 -1
@@ -19,7 +19,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
19
19
  * Admin-SDK-only by design: org creation, slug reservation, membership
20
20
  * and the projections the security rules authorize against are never
21
21
  * client-writable (docs/MULTI_TENANT_FIRESTORE.md §8).
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";
22
+ */ import { consentGroupForHost, checkHostCollaboratorQuota, checkSeatQuota, countCollaboratorSeats, countManagerSeatsExcluding, createResourceUid, generateOrgSlug, projectMemberResolvedPermissions, resolveOrgPermissions, isOrgWideMember, isValidOrgSlug, hostPermissionKeys, parseOwnerHandoff, usesCustomerSeat, 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
24
  import { activitySearchTokens } from "@aglyn/aglyn/app-utils/activity-search";
25
25
  import { nameSearchKey, nameSearchReversed, nameSearchTokens } from "@aglyn/aglyn/app-utils/name-search";
@@ -36,6 +36,7 @@ import firebaseAdmin from "./firebase-admin.js";
36
36
  import { enforceFreeWorkspaceCapInTransaction, readFreeWorkspaceCapConfig } from "./free-workspace-cap.js";
37
37
  import { deleteMemberHostProjections, syncHostProjectionForMembers, syncMemberHostProjections } from "./host-memberships.js";
38
38
  import { ORG_MEMBER_LIST_FIELD_NAMES, orgMemberListFields, orgMemberSearchTokens } from "./org-member-list-fields.js";
39
+ import { isStaffAccount } from "./staff-seat.js";
39
40
  import { updateExisting } from "./update-existing.js";
40
41
  import { attachWorkspaceDomain } from "./workspace-domains.js";
41
42
  const firestore = ()=>firebaseAdmin.app().firestore();
@@ -154,6 +155,7 @@ reservation, now = Date.now()) {
154
155
  * `OrgSlugTakenError` when the slug is reserved; slug validity is the
155
156
  * caller's job (API routes return 400 with policy copy).
156
157
  */ export async function createOrganization(options) {
158
+ var _options_ownerStaffSeat;
157
159
  const { name, slug, ownerUid, ownerEmail, ownerDisplayName } = options;
158
160
  const db = firestore();
159
161
  const orgId = createResourceUid();
@@ -164,6 +166,9 @@ reservation, now = Date.now()) {
164
166
  // the verdict knows the difference between "staff set no limit" and "we
165
167
  // could not read it", and never treats the second as the first.
166
168
  const capConfig = options.bypassFreeWorkspaceCap ? null : await readFreeWorkspaceCapConfig();
169
+ // Read outside the transaction for the same reason: an auth record, not a
170
+ // document this creation races with (AGL-3466).
171
+ const ownerStaffSeat = (_options_ownerStaffSeat = options.ownerStaffSeat) != null ? _options_ownerStaffSeat : await isStaffAccount(ownerUid);
167
172
  await db.runTransaction(async (tx)=>{
168
173
  const reservation = await tx.get(db.collection('orgSlugs').doc(slug));
169
174
  const held = reservation.exists ? reservation.data() : undefined;
@@ -263,7 +268,10 @@ reservation, now = Date.now()) {
263
268
  allHosts: true,
264
269
  email: ownerEmail != null ? ownerEmail : null,
265
270
  displayName: ownerDisplayName != null ? ownerDisplayName : null,
266
- joinedAt: FieldValue.serverTimestamp(),
271
+ joinedAt: FieldValue.serverTimestamp()
272
+ }, ownerStaffSeat ? {
273
+ staffSeat: true
274
+ } : {}, {
267
275
  /*
268
276
  * The rules projection, stamped AT CREATION (AGL-1038).
269
277
  *
@@ -1070,7 +1078,8 @@ const MEMBER_PROJECTION_WRITE = {
1070
1078
  * rather than leaving the customer to infer it from a rejected click.
1071
1079
  */ async function assertCollaboratorSeats(options) {
1072
1080
  const { orgRef, org, hostIds, self, read } = options;
1073
- if (!hostIds.length) return;
1081
+ // Platform staff take no seat (AGL-3466), so there is nothing to charge.
1082
+ if (!hostIds.length || self.staffSeat) return;
1074
1083
  const entries = await readSeatEntries(orgRef, read);
1075
1084
  for (const hostId of hostIds){
1076
1085
  const used = countCollaboratorSeats(entries, hostId, self);
@@ -1086,7 +1095,8 @@ const MEMBER_PROJECTION_WRITE = {
1086
1095
  * every host and pays for it with a manager seat.
1087
1096
  */ function newlyScopedHosts(options) {
1088
1097
  var _ref;
1089
- const { role, allHosts, hostAccess, existing } = options;
1098
+ const { role, allHosts, hostAccess, existing, staffSeat } = options;
1099
+ if (staffSeat) return [];
1090
1100
  if (isOrgWideMember({
1091
1101
  role,
1092
1102
  allHosts,
@@ -1094,7 +1104,9 @@ const MEMBER_PROJECTION_WRITE = {
1094
1104
  })) {
1095
1105
  return [];
1096
1106
  }
1097
- const prior = (_ref = existing == null ? void 0 : existing.hostAccess) != null ? _ref : {};
1107
+ // A row stamped as staff held no seat on any of its sites, so a former
1108
+ // staff member keeping a site is taking that seat now, not re-writing one.
1109
+ const prior = usesCustomerSeat(existing) ? (_ref = existing == null ? void 0 : existing.hostAccess) != null ? _ref : {} : {};
1098
1110
  return Object.keys(hostAccess).filter((hostId)=>!prior[hostId]);
1099
1111
  }
1100
1112
  /**
@@ -1129,7 +1141,7 @@ const MEMBER_PROJECTION_WRITE = {
1129
1141
  * transactional path.
1130
1142
  */ export async function collaboratorSeatRefusal(options) {
1131
1143
  const { orgId, org, hostIds, self } = options;
1132
- if (!hostIds.length) return null;
1144
+ if (!hostIds.length || (self == null ? void 0 : self.staffSeat)) return null;
1133
1145
  try {
1134
1146
  await assertCollaboratorSeats({
1135
1147
  orgRef: firestore().collection('orgs').doc(orgId),
@@ -1207,7 +1219,8 @@ const MEMBER_PROJECTION_WRITE = {
1207
1219
  * may be added: the cap binds ADMISSION, never ACCESS.
1208
1220
  */ async function assertManagerSeats(options) {
1209
1221
  const { orgRef, org, becomesManager, self, read } = options;
1210
- if (!becomesManager) return;
1222
+ // Platform staff take no seat (AGL-3466).
1223
+ if (!becomesManager || self.staffSeat) return;
1211
1224
  const entries = await readSeatEntries(orgRef, read);
1212
1225
  const used = countManagerSeatsExcluding(entries, self);
1213
1226
  const quota = checkSeatQuota(org, 'managers', used);
@@ -1230,6 +1243,7 @@ const MEMBER_PROJECTION_WRITE = {
1230
1243
  * and gives one up on the collaborator side; that is a real transition and is
1231
1244
  * charged.
1232
1245
  */ function becomesOrgManager(options) {
1246
+ if (options.staffSeat) return false;
1233
1247
  const next = isOrgWideMember({
1234
1248
  role: options.role,
1235
1249
  allHosts: options.allHosts,
@@ -1239,7 +1253,9 @@ const MEMBER_PROJECTION_WRITE = {
1239
1253
  // An ABSENT row is not a manager, and `isOrgWideMember(undefined)` is
1240
1254
  // already false — but saying so explicitly keeps the "was it one before?"
1241
1255
  // question readable next to the legacy shape that predates `allHosts`.
1242
- return !options.existing || !isOrgWideMember(options.existing);
1256
+ // A row stamped as staff held no seat either (AGL-3466): a former staff
1257
+ // manager whose row is rewritten is taking a customer seat now.
1258
+ return !options.existing || !usesCustomerSeat(options.existing) || !isOrgWideMember(options.existing);
1243
1259
  }
1244
1260
  /**
1245
1261
  * Turn a manager-seat refusal into the 403 the admitting routes return, or
@@ -1272,7 +1288,7 @@ const MEMBER_PROJECTION_WRITE = {
1272
1288
  * access, because access is only ever granted through the transactional path.
1273
1289
  */ export async function managerSeatRefusal(options) {
1274
1290
  const { orgId, org, becomesManager, self } = options;
1275
- if (!becomesManager) return null;
1291
+ if (!becomesManager || (self == null ? void 0 : self.staffSeat)) return null;
1276
1292
  try {
1277
1293
  await assertManagerSeats({
1278
1294
  orgRef: firestore().collection('orgs').doc(orgId),
@@ -1317,6 +1333,19 @@ const MEMBER_PROJECTION_WRITE = {
1317
1333
  status: 409
1318
1334
  });
1319
1335
  }
1336
+ /**
1337
+ * The `staffSeat` field of a member write (AGL-3466): stamped while the
1338
+ * account holds the `staff` claim, and taken off a row that still carries a
1339
+ * stamp once it does not, so the row converges on the claim the next time
1340
+ * anything writes it.
1341
+ */ function staffSeatWrite(staffSeat, existing) {
1342
+ if (staffSeat) return {
1343
+ staffSeat: true
1344
+ };
1345
+ return (existing == null ? void 0 : existing.staffSeat) !== undefined ? {
1346
+ staffSeat: FieldValue.delete()
1347
+ } : {};
1348
+ }
1320
1349
  /**
1321
1350
  * Creates or updates a member transactionally with its reverse-index
1322
1351
  * entry, then re-syncs host projections.
@@ -1372,6 +1401,10 @@ const MEMBER_PROJECTION_WRITE = {
1372
1401
  // refusing here is what lets the spec assert that NOTHING was written
1373
1402
  // rather than that a throw happened somewhere.
1374
1403
  if (role === 'owner') throw new OrgOwnerSeatError('grant');
1404
+ // Platform staff take none of the customer's seats (AGL-3466). Asked of
1405
+ // the auth record at write time, so the claim decides it rather than
1406
+ // anything the caller says, and a lookup that fails answers "not staff".
1407
+ const staffSeat = await isStaffAccount(uid);
1375
1408
  const db = firestore();
1376
1409
  await db.runTransaction(async (tx)=>{
1377
1410
  var _org_name, _org_slug;
@@ -1381,6 +1414,7 @@ const MEMBER_PROJECTION_WRITE = {
1381
1414
  const org = orgSnapshot.data();
1382
1415
  const memberRef = db.collection('orgs').doc(orgId).collection('members').doc(uid);
1383
1416
  const existing = await tx.get(memberRef);
1417
+ const existingData = existing.data();
1384
1418
  // The owner's own row is not writable here (AGL-1888). Both facts, not
1385
1419
  // one standing in for the other — see the note on this function.
1386
1420
  if (org.ownerUid === uid || ((_existing_data = existing.data()) == null ? void 0 : _existing_data.role) === 'owner') {
@@ -1400,12 +1434,14 @@ const MEMBER_PROJECTION_WRITE = {
1400
1434
  role,
1401
1435
  allHosts: allHosts != null ? allHosts : false,
1402
1436
  hostAccess: hostAccess != null ? hostAccess : {},
1403
- existing: existing.data()
1437
+ existing: existingData,
1438
+ staffSeat
1404
1439
  }),
1405
1440
  self: {
1406
1441
  uid,
1407
1442
  email,
1408
- emails: seatAliasEmails
1443
+ emails: seatAliasEmails,
1444
+ staffSeat
1409
1445
  },
1410
1446
  read: (query)=>tx.get(query)
1411
1447
  });
@@ -1422,12 +1458,14 @@ const MEMBER_PROJECTION_WRITE = {
1422
1458
  role,
1423
1459
  allHosts: allHosts != null ? allHosts : false,
1424
1460
  hostAccess: hostAccess != null ? hostAccess : {},
1425
- existing: existing.data()
1461
+ existing: existingData,
1462
+ staffSeat
1426
1463
  }),
1427
1464
  self: {
1428
1465
  uid,
1429
1466
  email,
1430
- emails: seatAliasEmails
1467
+ emails: seatAliasEmails,
1468
+ staffSeat
1431
1469
  },
1432
1470
  read: (query)=>tx.get(query)
1433
1471
  });
@@ -1435,7 +1473,7 @@ const MEMBER_PROJECTION_WRITE = {
1435
1473
  role,
1436
1474
  allHosts: allHosts != null ? allHosts : false,
1437
1475
  hostAccess: hostAccess != null ? hostAccess : {}
1438
- }, roleId !== undefined ? {
1476
+ }, staffSeatWrite(staffSeat, existingData), roleId !== undefined ? {
1439
1477
  roleId
1440
1478
  } : {}, email !== undefined ? {
1441
1479
  email
@@ -1572,6 +1610,79 @@ const MEMBER_PROJECTION_WRITE = {
1572
1610
  }
1573
1611
  return written;
1574
1612
  }
1613
+ /**
1614
+ * The writes that move the owner seat, inside the caller's transaction.
1615
+ *
1616
+ * THE ONE COPY. `transferOrgOwnership` and `acceptOwnerHandoff` both call
1617
+ * this, so an org can never end up with an owner one door produced and the
1618
+ * other would not recognize: one `ownerUid`, exactly one `role: 'owner'` row,
1619
+ * and reverse-index entries that agree with both.
1620
+ *
1621
+ * Every member write is a MERGE, which is what keeps a `staffSeat` stamp
1622
+ * where it was (AGL-3466): a staff owner handing over and staying on remains
1623
+ * a no-seat admin, and a staff member taking over remains a no-seat owner.
1624
+ *
1625
+ * `createdByUid` is not written here and must never be (AGL-2265) — see the
1626
+ * note on `transferOrgOwnership`.
1627
+ */ function writeOwnershipMove(tx, db, move) {
1628
+ var _move_newOwnerRow, _move_newOwnerIndex;
1629
+ const { orgId, fromUid, toUid, previousOwner } = move;
1630
+ const orgRef = db.collection('orgs').doc(orgId);
1631
+ tx.set(orgRef, {
1632
+ ownerUid: toUid,
1633
+ updatedAt: FieldValue.serverTimestamp()
1634
+ }, {
1635
+ merge: true
1636
+ });
1637
+ tx.set(orgRef.collection('members').doc(toUid), _extends({}, (_move_newOwnerRow = move.newOwnerRow) != null ? _move_newOwnerRow : {}, {
1638
+ role: 'owner',
1639
+ allHosts: true
1640
+ }), {
1641
+ merge: true
1642
+ });
1643
+ tx.set(db.collection('users').doc(toUid).collection('orgs').doc(orgId), // Both principals end up owner/admin, which is org-wide reach whatever
1644
+ // they were before — a promoted site collaborator must lose the scoped
1645
+ // console along with the scoped membership (AGL-1032).
1646
+ _extends({}, (_move_newOwnerIndex = move.newOwnerIndex) != null ? _move_newOwnerIndex : {}, {
1647
+ role: 'owner',
1648
+ orgWide: true
1649
+ }), {
1650
+ merge: true
1651
+ });
1652
+ if (previousOwner === 'stay') {
1653
+ tx.set(orgRef.collection('members').doc(fromUid), {
1654
+ role: 'admin'
1655
+ }, {
1656
+ merge: true
1657
+ });
1658
+ tx.set(db.collection('users').doc(fromUid).collection('orgs').doc(orgId), {
1659
+ role: 'admin',
1660
+ orgWide: true
1661
+ }, {
1662
+ merge: true
1663
+ });
1664
+ return;
1665
+ }
1666
+ // Leaving: the same three documents `removeOrgMember` deletes, inside this
1667
+ // transaction so the org is never briefly without its outgoing owner AND
1668
+ // without its incoming one.
1669
+ tx.delete(orgRef.collection('members').doc(fromUid));
1670
+ tx.delete(orgRef.collection(MEMBER_EMAIL_ALIASES_COLLECTION).doc(fromUid));
1671
+ tx.delete(db.collection('users').doc(fromUid).collection('orgs').doc(orgId));
1672
+ }
1673
+ /**
1674
+ * The projections an ownership move changes, after its transaction: the
1675
+ * whole roster's auth projections, and each principal's host reverse index
1676
+ * (the owner spans every host — AGL-844). A principal who left has theirs
1677
+ * deleted, as `removeOrgMember` does.
1678
+ */ async function settleOwnershipMove(move) {
1679
+ const { orgId, fromUid, toUid, previousOwner } = move;
1680
+ await syncOrgAuthProjections(orgId);
1681
+ await Promise.all([
1682
+ syncMemberHostProjections(orgId, toUid),
1683
+ previousOwner === 'stay' ? syncMemberHostProjections(orgId, fromUid) : deleteMemberHostProjections(orgId, fromUid)
1684
+ ]);
1685
+ }
1575
1686
  /**
1576
1687
  * Transfers org ownership (AGL-232): the target must already be on the
1577
1688
  * roster; the previous owner steps down to admin. One transaction across
@@ -1587,6 +1698,12 @@ const MEMBER_PROJECTION_WRITE = {
1587
1698
  */ export async function transferOrgOwnership(orgId, fromUid, toUid) {
1588
1699
  if (fromUid === toUid) throw new Error('Target already owns this org');
1589
1700
  const db = firestore();
1701
+ const move = {
1702
+ orgId,
1703
+ fromUid,
1704
+ toUid,
1705
+ previousOwner: 'stay'
1706
+ };
1590
1707
  await db.runTransaction(async (tx)=>{
1591
1708
  const orgRef = db.collection('orgs').doc(orgId);
1592
1709
  const orgSnapshot = await tx.get(orgRef);
@@ -1600,45 +1717,9 @@ const MEMBER_PROJECTION_WRITE = {
1600
1717
  if (!target.exists) {
1601
1718
  throw new Error('The new owner must already be an org member');
1602
1719
  }
1603
- tx.set(orgRef, {
1604
- ownerUid: toUid,
1605
- updatedAt: FieldValue.serverTimestamp()
1606
- }, {
1607
- merge: true
1608
- });
1609
- tx.set(targetRef, {
1610
- role: 'owner',
1611
- allHosts: true
1612
- }, {
1613
- merge: true
1614
- });
1615
- tx.set(orgRef.collection('members').doc(fromUid), {
1616
- role: 'admin'
1617
- }, {
1618
- merge: true
1619
- });
1620
- tx.set(db.collection('users').doc(toUid).collection('orgs').doc(orgId), // Both principals end up owner/admin, which is org-wide reach whatever
1621
- // they were before — a promoted site collaborator must lose the scoped
1622
- // console along with the scoped membership (AGL-1032).
1623
- {
1624
- role: 'owner',
1625
- orgWide: true
1626
- }, {
1627
- merge: true
1628
- });
1629
- tx.set(db.collection('users').doc(fromUid).collection('orgs').doc(orgId), {
1630
- role: 'admin',
1631
- orgWide: true
1632
- }, {
1633
- merge: true
1634
- });
1720
+ writeOwnershipMove(tx, db, move);
1635
1721
  });
1636
- await syncOrgAuthProjections(orgId);
1637
- // Both principals' host access changed (owner spans every host) — AGL-844.
1638
- await Promise.all([
1639
- syncMemberHostProjections(orgId, toUid),
1640
- syncMemberHostProjections(orgId, fromUid)
1641
- ]);
1722
+ await settleOwnershipMove(move);
1642
1723
  /*
1643
1724
  * A workspace changing hands is the highest-consequence thing that can
1644
1725
  * happen to an account, and until AGL-118 it left no trace anywhere: the
@@ -1670,6 +1751,248 @@ const MEMBER_PROJECTION_WRITE = {
1670
1751
  name: toEmail
1671
1752
  } : {}));
1672
1753
  }
1754
+ /**
1755
+ * An owner handoff refused because it would need a team seat the plan does
1756
+ * not have (AGL-3466).
1757
+ *
1758
+ * The loophole guard. A handoff reserves no seat, because the owner seat
1759
+ * MOVES — but a `stay` handoff keeps the outgoing owner as an admin, so the
1760
+ * workspace ends with one more manager than it started with. On Free that is
1761
+ * a second manager on a one-seat plan, and without this check any owner could
1762
+ * mint free managers by handing the workspace back and forth. The refusal
1763
+ * names the way out: the outgoing owner leaving instead.
1764
+ *
1765
+ * Separate from {@link ManagerSeatLimitError} because the remedy is
1766
+ * different, and a client that branches on `code` must be able to tell.
1767
+ */ export class OwnerHandoffSeatError extends Error {
1768
+ constructor(previousOwner, quota){
1769
+ super(previousOwner === 'stay' ? `Team seats full (${quota.limit}) — the current owner can't stay on ` + 'as an admin after the handoff without a seat of their own. ' + 'Choose "Leave after the handoff", or add a seat first.' : `Team seats full (${quota.limit}) — the new owner needs a team ` + 'seat, and every seat is taken. Remove a member or add a seat first.');
1770
+ this.name = 'OwnerHandoffSeatError';
1771
+ this.limit = quota.limit;
1772
+ this.upgradeRequired = quota.upgradeRequired;
1773
+ this.previousOwner = previousOwner;
1774
+ }
1775
+ }
1776
+ /**
1777
+ * An owner handoff that can no longer be accepted as asked (AGL-3466): the
1778
+ * invite was answered or withdrawn in the meantime, is not a handoff, is
1779
+ * addressed to somebody else, or the accepter already owns the workspace.
1780
+ */ export class OwnerHandoffError extends Error {
1781
+ constructor(reason){
1782
+ super(reason === 'not-pending' ? 'This handoff was already answered or withdrawn.' : reason === 'not-handoff' ? 'This invitation does not hand the workspace over.' : reason === 'not-addressed' ? 'This handoff is for a different (or unverified) email.' : 'You already own this workspace.');
1783
+ this.name = 'OwnerHandoffError';
1784
+ this.reason = reason;
1785
+ }
1786
+ }
1787
+ /**
1788
+ * Turn a handoff refusal into the response the invite route returns, or
1789
+ * null when the error is something else and must keep propagating.
1790
+ */ export function ownerHandoffRefusalResponse(error) {
1791
+ if (error instanceof OwnerHandoffSeatError) {
1792
+ return Response.json({
1793
+ error: error.message,
1794
+ code: 'owner_handoff_seat',
1795
+ limit: error.limit,
1796
+ upgradeRequired: error.upgradeRequired,
1797
+ previousOwner: error.previousOwner
1798
+ }, {
1799
+ status: 403
1800
+ });
1801
+ }
1802
+ if (error instanceof OwnerHandoffError) {
1803
+ return Response.json({
1804
+ error: error.message,
1805
+ code: 'owner_handoff',
1806
+ reason: error.reason
1807
+ }, {
1808
+ status: error.reason === 'not-addressed' ? 403 : 409
1809
+ });
1810
+ }
1811
+ return null;
1812
+ }
1813
+ /**
1814
+ * Does accepting this handoff leave the workspace with one more manager
1815
+ * seat in use than it has now? (AGL-3466)
1816
+ *
1817
+ * The incoming owner takes the owner seat. That costs nothing when they are
1818
+ * staff, or already hold a manager seat here. Otherwise it is one more seat —
1819
+ * unless the outgoing owner LEAVES and frees one, which they only do if they
1820
+ * held a seat to free: a staff owner leaving frees nothing.
1821
+ */ function handoffTakesManagerSeat(options) {
1822
+ const { previousOwner, inviteeStaff, invitee, owner } = options;
1823
+ const heldSeat = (row)=>!!row && usesCustomerSeat(row) && isOrgWideMember(row);
1824
+ if (inviteeStaff || heldSeat(invitee)) return false;
1825
+ return !(previousOwner === 'leave' && heldSeat(owner));
1826
+ }
1827
+ /**
1828
+ * The seat check a handoff answers, at send and again at accept.
1829
+ *
1830
+ * Grandfathered the way every seat gate here is: only the transition is
1831
+ * charged, so a handoff that leaves the seat count where it was is never
1832
+ * refused, however far over its cap the workspace already is.
1833
+ */ async function assertOwnerHandoffSeat(options) {
1834
+ if (!handoffTakesManagerSeat(options)) return;
1835
+ const entries = await readSeatEntries(options.orgRef, options.read);
1836
+ const used = countManagerSeatsExcluding(entries, options.self);
1837
+ const quota = checkSeatQuota(options.org, 'managers', used);
1838
+ if (!quota.allowed) throw new OwnerHandoffSeatError(options.previousOwner, quota);
1839
+ }
1840
+ /**
1841
+ * The handoff seat check, asked when the invitation is SENT (AGL-3466), so
1842
+ * nobody is mailed a handoff that will be refused when they click it.
1843
+ *
1844
+ * Not the enforcement — `acceptOwnerHandoff` asks again inside its
1845
+ * transaction, against the account that actually accepts. Here the invitee
1846
+ * is only an address, so their current seat is read off the roster row that
1847
+ * carries it.
1848
+ *
1849
+ * @returns the 403 to send, or null to proceed.
1850
+ */ export async function ownerHandoffSeatRefusal(options) {
1851
+ const { orgId, previousOwner, inviteeStaff } = options;
1852
+ const email = options.email.trim().toLowerCase();
1853
+ const db = firestore();
1854
+ const orgRef = db.collection('orgs').doc(orgId);
1855
+ const orgSnapshot = await orgRef.get();
1856
+ if (!orgSnapshot.exists) return null;
1857
+ const org = orgSnapshot.data();
1858
+ const ownerSnapshot = org.ownerUid ? await orgRef.collection('members').doc(org.ownerUid).get() : null;
1859
+ const roster = await orgRef.collection('members').where('email', '==', email).limit(1).get();
1860
+ try {
1861
+ await assertOwnerHandoffSeat({
1862
+ orgRef,
1863
+ org,
1864
+ previousOwner,
1865
+ inviteeStaff,
1866
+ invitee: roster.empty ? undefined : roster.docs[0].data(),
1867
+ owner: (ownerSnapshot == null ? void 0 : ownerSnapshot.exists) ? ownerSnapshot.data() : undefined,
1868
+ self: {
1869
+ email
1870
+ },
1871
+ read: (query)=>query.get()
1872
+ });
1873
+ } catch (error) {
1874
+ const refusal = ownerHandoffRefusalResponse(error);
1875
+ if (refusal) return refusal;
1876
+ throw error;
1877
+ }
1878
+ return null;
1879
+ }
1880
+ /**
1881
+ * Accept an owner handoff (AGL-3466): the invitee becomes the owner, the
1882
+ * outgoing owner stays on as an admin or leaves, and the invite is marked
1883
+ * accepted — all in ONE transaction, with the seat check inside it.
1884
+ *
1885
+ * The move itself is {@link writeOwnershipMove}, the same writes a transfer
1886
+ * makes. What this adds is what an invitation needs that a transfer does not:
1887
+ * the incoming owner may not be on the roster yet, so their row is created
1888
+ * with the identity a join would give it; and the outgoing owner may leave.
1889
+ *
1890
+ * The SSO lockout check is the CALLER's, for the reason
1891
+ * `assessOwnershipTransferLockout` gives: it cannot live in this library
1892
+ * without an import cycle, and `sso-transfer-lockout-wiring.spec.ts` pins
1893
+ * that every caller of this asks it first.
1894
+ */ export async function acceptOwnerHandoff(options) {
1895
+ const { orgId, inviteId, uid, email, displayName, photoURL } = options;
1896
+ const addresses = new Set([
1897
+ email,
1898
+ ...options.emails
1899
+ ].map((address)=>address.trim().toLowerCase()));
1900
+ // The accepter's claim, read at accept rather than trusted from the send.
1901
+ const inviteeStaff = await isStaffAccount(uid);
1902
+ const db = firestore();
1903
+ let move = null;
1904
+ let ownerEmail = null;
1905
+ await db.runTransaction(async (tx)=>{
1906
+ var _invite_email, _org_name, _org_slug;
1907
+ const orgRef = db.collection('orgs').doc(orgId);
1908
+ const orgSnapshot = await tx.get(orgRef);
1909
+ if (!orgSnapshot.exists) throw new Error(`Unknown org: ${orgId}`);
1910
+ const org = orgSnapshot.data();
1911
+ const inviteRef = orgRef.collection('invites').doc(inviteId);
1912
+ const inviteSnapshot = await tx.get(inviteRef);
1913
+ const invite = inviteSnapshot.data();
1914
+ if (!inviteSnapshot.exists || !invite || invite['acceptedAt']) {
1915
+ throw new OwnerHandoffError('not-pending');
1916
+ }
1917
+ const handoff = parseOwnerHandoff(invite.handoff);
1918
+ if (invite['role'] !== 'owner' || !handoff) {
1919
+ throw new OwnerHandoffError('not-handoff');
1920
+ }
1921
+ if (!addresses.has(String((_invite_email = invite['email']) != null ? _invite_email : '').toLowerCase())) {
1922
+ throw new OwnerHandoffError('not-addressed');
1923
+ }
1924
+ const fromUid = typeof org.ownerUid === 'string' ? org.ownerUid : '';
1925
+ if (!fromUid) throw new Error(`Org ${orgId} has no owner to hand over`);
1926
+ if (fromUid === uid) throw new OwnerHandoffError('already-owner');
1927
+ const inviteeSnapshot = await tx.get(orgRef.collection('members').doc(uid));
1928
+ const ownerSnapshot = await tx.get(orgRef.collection('members').doc(fromUid));
1929
+ const invitee = inviteeSnapshot.data();
1930
+ const owner = ownerSnapshot.data();
1931
+ ownerEmail = typeof (owner == null ? void 0 : owner.email) === 'string' ? owner.email : null;
1932
+ await assertOwnerHandoffSeat({
1933
+ orgRef,
1934
+ org,
1935
+ previousOwner: handoff.previousOwner,
1936
+ inviteeStaff,
1937
+ invitee,
1938
+ owner,
1939
+ self: {
1940
+ uid,
1941
+ email,
1942
+ emails: [
1943
+ ...addresses
1944
+ ]
1945
+ },
1946
+ read: (query)=>tx.get(query)
1947
+ });
1948
+ move = {
1949
+ orgId,
1950
+ fromUid,
1951
+ toUid: uid,
1952
+ previousOwner: handoff.previousOwner,
1953
+ newOwnerRow: _extends({
1954
+ email
1955
+ }, displayName !== undefined ? {
1956
+ displayName
1957
+ } : {}, photoURL !== undefined ? {
1958
+ photoURL
1959
+ } : {}, staffSeatWrite(inviteeStaff, invitee), inviteeSnapshot.exists ? {} : _extends({
1960
+ hostAccess: {},
1961
+ joinedAt: FieldValue.serverTimestamp()
1962
+ }, typeof invite['invitedBy'] === 'string' ? {
1963
+ invitedBy: invite['invitedBy']
1964
+ } : {})),
1965
+ newOwnerIndex: {
1966
+ orgName: (_org_name = org.name) != null ? _org_name : null,
1967
+ slug: (_org_slug = org.slug) != null ? _org_slug : null
1968
+ }
1969
+ };
1970
+ writeOwnershipMove(tx, db, move);
1971
+ tx.set(inviteRef, {
1972
+ acceptedAt: FieldValue.serverTimestamp(),
1973
+ acceptedBy: uid
1974
+ }, {
1975
+ merge: true
1976
+ });
1977
+ });
1978
+ // Assigned inside the transaction, which has returned without throwing.
1979
+ const settled = move;
1980
+ await settleOwnershipMove(settled);
1981
+ // Both principals, as the transfer's row names them, written by the
1982
+ // person who accepted — the only party who could make this happen.
1983
+ await logOrgActivity(orgId, {
1984
+ uid,
1985
+ email
1986
+ }, settled.previousOwner === 'stay' ? `Took over the workspace; ${ownerEmail != null ? ownerEmail : 'the previous owner'} stays on as an admin` : `Took over the workspace; ${ownerEmail != null ? ownerEmail : 'the previous owner'} left it`, {
1987
+ type: 'member',
1988
+ id: uid,
1989
+ name: email
1990
+ });
1991
+ return {
1992
+ previousOwnerUid: settled.fromUid,
1993
+ previousOwner: settled.previousOwner
1994
+ };
1995
+ }
1673
1996
  /**
1674
1997
  * Grants (or updates) per-host access for a uid without disturbing an
1675
1998
  * existing membership's org role or allHosts flag (AGL-238: the host user
@@ -1677,6 +2000,8 @@ const MEMBER_PROJECTION_WRITE = {
1677
2000
  * just this host when the uid is not on the roster yet.
1678
2001
  */ export async function grantHostAccess(options) {
1679
2002
  const { orgId, uid, hostId, role, email, displayName, invitedBy } = options;
2003
+ // Platform staff take no collaborator seat either (AGL-3466).
2004
+ const staffSeat = await isStaffAccount(uid);
1680
2005
  const db = firestore();
1681
2006
  await db.runTransaction(async (tx)=>{
1682
2007
  const orgRef = db.collection('orgs').doc(orgId);
@@ -1685,6 +2010,7 @@ const MEMBER_PROJECTION_WRITE = {
1685
2010
  const org = orgSnapshot.data();
1686
2011
  const memberRef = orgRef.collection('members').doc(uid);
1687
2012
  const existing = await tx.get(memberRef);
2013
+ const existingData = existing.data();
1688
2014
  // Collaborator seat cap (AGL-2068). This door DID meter, but against
1689
2015
  // `hosts/{hostId}/members` — a display roster only its own route writes,
1690
2016
  // so it could not see anyone admitted by invite or by `/api/orgs/members`
@@ -1700,17 +2026,21 @@ const MEMBER_PROJECTION_WRITE = {
1700
2026
  // precisely so it is not locked out, must not be re-classified into a
1701
2027
  // collaborator seat by the act of writing a host key onto it.
1702
2028
  hostIds: (()=>{
1703
- var _current_hostAccess;
1704
- const current = existing.data();
1705
- if (existing.exists && isOrgWideMember(current)) return [];
1706
- if (current == null ? void 0 : (_current_hostAccess = current.hostAccess) == null ? void 0 : _current_hostAccess[hostId]) return [];
2029
+ var _existingData_hostAccess;
2030
+ if (existing.exists && isOrgWideMember(existingData)) return [];
2031
+ // A stamped row held no seat on this site, so keeping it after the
2032
+ // claim is gone takes one now (AGL-3466).
2033
+ if (usesCustomerSeat(existingData) && (existingData == null ? void 0 : (_existingData_hostAccess = existingData.hostAccess) == null ? void 0 : _existingData_hostAccess[hostId])) {
2034
+ return [];
2035
+ }
1707
2036
  return [
1708
2037
  hostId
1709
2038
  ];
1710
2039
  })(),
1711
2040
  self: {
1712
2041
  uid,
1713
- email
2042
+ email,
2043
+ staffSeat
1714
2044
  },
1715
2045
  read: (query)=>tx.get(query)
1716
2046
  });
@@ -1722,7 +2052,7 @@ const MEMBER_PROJECTION_WRITE = {
1722
2052
  hostAccess: {
1723
2053
  [hostId]: role
1724
2054
  }
1725
- }, email !== undefined ? {
2055
+ }, staffSeatWrite(staffSeat, existingData), email !== undefined ? {
1726
2056
  email
1727
2057
  } : {}, displayName !== undefined ? {
1728
2058
  displayName