@bevel-software/platform-core-backend 0.27.0 → 0.28.1

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 (99) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +2 -2
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +7 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +5 -0
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/modules/access/access-control.interface.d.ts +14 -2
  9. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  10. package/dist/modules/access/access-control.service.d.ts +1 -0
  11. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  12. package/dist/modules/access/access-control.service.js +83 -42
  13. package/dist/modules/access/access-control.service.js.map +1 -1
  14. package/dist/modules/access/access.routes.d.ts +4 -1
  15. package/dist/modules/access/access.routes.d.ts.map +1 -1
  16. package/dist/modules/access/access.routes.js +66 -7
  17. package/dist/modules/access/access.routes.js.map +1 -1
  18. package/dist/modules/access/roles-admin.service.d.ts +35 -6
  19. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  20. package/dist/modules/access/roles-admin.service.js +36 -9
  21. package/dist/modules/access/roles-admin.service.js.map +1 -1
  22. package/dist/modules/access/user-access-removal.service.js +1 -1
  23. package/dist/modules/access/user-access-removal.service.js.map +1 -1
  24. package/dist/modules/auth/account-hooks.d.ts +46 -0
  25. package/dist/modules/auth/account-hooks.d.ts.map +1 -0
  26. package/dist/modules/auth/account-hooks.js +25 -0
  27. package/dist/modules/auth/account-hooks.js.map +1 -0
  28. package/dist/modules/auth/account-switch-guard.d.ts +15 -0
  29. package/dist/modules/auth/account-switch-guard.d.ts.map +1 -0
  30. package/dist/modules/auth/account-switch-guard.js +2 -0
  31. package/dist/modules/auth/account-switch-guard.js.map +1 -0
  32. package/dist/modules/auth/account-switch-lock.d.ts +34 -0
  33. package/dist/modules/auth/account-switch-lock.d.ts.map +1 -0
  34. package/dist/modules/auth/account-switch-lock.js +48 -0
  35. package/dist/modules/auth/account-switch-lock.js.map +1 -0
  36. package/dist/modules/auth/account.routes.d.ts +4 -1
  37. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  38. package/dist/modules/auth/account.routes.js +100 -10
  39. package/dist/modules/auth/account.routes.js.map +1 -1
  40. package/dist/modules/auth/auth.service.d.ts +101 -6
  41. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  42. package/dist/modules/auth/auth.service.js +246 -51
  43. package/dist/modules/auth/auth.service.js.map +1 -1
  44. package/dist/modules/auth/password-hash.d.ts +5 -2
  45. package/dist/modules/auth/password-hash.d.ts.map +1 -1
  46. package/dist/modules/auth/password-hash.js +5 -2
  47. package/dist/modules/auth/password-hash.js.map +1 -1
  48. package/dist/modules/database/pg-error.d.ts +9 -0
  49. package/dist/modules/database/pg-error.d.ts.map +1 -0
  50. package/dist/modules/database/pg-error.js +17 -0
  51. package/dist/modules/database/pg-error.js.map +1 -0
  52. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  53. package/dist/modules/workflow/workflow.service.js +2 -1
  54. package/dist/modules/workflow/workflow.service.js.map +1 -1
  55. package/kb-template/access.md +2 -1
  56. package/package.json +3 -3
  57. package/src/__tests__/pg-scratch.ts +48 -0
  58. package/src/core/create-core-server.ts +2 -0
  59. package/src/core/create-core-services.ts +11 -0
  60. package/src/modules/access/__tests__/access-control.admin-root-floor.test.ts +24 -3
  61. package/src/modules/access/__tests__/access-control.download-read.test.ts +3 -1
  62. package/src/modules/access/__tests__/access-control.service.test.ts +77 -16
  63. package/src/modules/access/__tests__/access.routes.admins.test.ts +203 -0
  64. package/src/modules/access/__tests__/access.routes.batch.test.ts +25 -5
  65. package/src/modules/access/__tests__/access.routes.folder-governs.test.ts +1 -0
  66. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +1 -1
  67. package/src/modules/access/__tests__/access.routes.overrides.test.ts +1 -1
  68. package/src/modules/access/__tests__/access.routes.proposal-branch.test.ts +1 -0
  69. package/src/modules/access/__tests__/access.routes.prospective.test.ts +1 -0
  70. package/src/modules/access/__tests__/access.routes.revoke.test.ts +1 -1
  71. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +1 -0
  72. package/src/modules/access/__tests__/change-read-gate.test.ts +36 -5
  73. package/src/modules/access/__tests__/roles-admin.security.test.ts +1 -1
  74. package/src/modules/access/__tests__/roles-admin.service.test.ts +15 -2
  75. package/src/modules/access/__tests__/roles.routes.test.ts +132 -7
  76. package/src/modules/access/__tests__/user-access-removal.service.test.ts +2 -2
  77. package/src/modules/access/__tests__/users-db-double.ts +12 -3
  78. package/src/modules/access/access-control.interface.ts +15 -2
  79. package/src/modules/access/access-control.service.ts +93 -49
  80. package/src/modules/access/access.routes.ts +67 -6
  81. package/src/modules/access/roles-admin.service.ts +51 -11
  82. package/src/modules/access/user-access-removal.service.ts +1 -1
  83. package/src/modules/auth/__tests__/account-deactivation.test.ts +145 -16
  84. package/src/modules/auth/__tests__/account-hooks.test.ts +57 -0
  85. package/src/modules/auth/__tests__/account-switch-lock.pg.test.ts +78 -0
  86. package/src/modules/auth/__tests__/account-switch-lock.test.ts +43 -0
  87. package/src/modules/auth/__tests__/account.routes.test.ts +382 -6
  88. package/src/modules/auth/__tests__/auth.service.created.pg.test.ts +69 -0
  89. package/src/modules/auth/__tests__/auth.service.test.ts +159 -1
  90. package/src/modules/auth/account-hooks.ts +69 -0
  91. package/src/modules/auth/account-switch-guard.ts +14 -0
  92. package/src/modules/auth/account-switch-lock.ts +58 -0
  93. package/src/modules/auth/account.routes.ts +112 -10
  94. package/src/modules/auth/auth.service.ts +268 -52
  95. package/src/modules/auth/password-hash.ts +5 -2
  96. package/src/modules/database/__tests__/pg-error.test.ts +21 -0
  97. package/src/modules/database/__tests__/pii-backfill.pg.test.ts +6 -27
  98. package/src/modules/database/pg-error.ts +15 -0
  99. package/src/modules/workflow/workflow.service.ts +2 -1
@@ -21,6 +21,7 @@ import type { WorkspaceService } from '../workspace/workspace.service.js';
21
21
  import type { KbContext } from '../../shared/kb-context.js';
22
22
  import { branchForWorkspaceId } from '../../shared/workspace-id.js';
23
23
  import type { AuthService } from '../auth/auth.service.js';
24
+ import type { IAccountSwitchGuard } from '../auth/account-switch-guard.js';
24
25
  import type { WorkflowService } from '../workflow/workflow.service.js';
25
26
  import type { WorkflowEventBus } from '../workflow/event-bus.js';
26
27
  import { WorkflowDomainError } from '../../shared/domain-errors.js';
@@ -51,6 +52,7 @@ import { RolesAdminService } from './roles-admin.service.js';
51
52
  import type { Principal } from '../access-model/access-splice.js';
52
53
  import type { Database } from '../database/connection.js';
53
54
  import { users } from '../database/schema.js';
55
+ import { canonicalEmail } from '../../shared/email-identity.js';
54
56
  import '../auth/auth.middleware.js';
55
57
 
56
58
  /** Verbs the share UI may grant. Verbs are independent — `download` is grantable on its own. */
@@ -85,6 +87,8 @@ export function createAccessRoutes(
85
87
  accessControl: IAccessControl,
86
88
  workspaceService: WorkspaceService,
87
89
  authService: AuthService,
90
+ /** Auth's switch-off coordination, for the invite's promotion (`ifActive`). */
91
+ switchGuard: IAccountSwitchGuard,
88
92
  workflowService: WorkflowService,
89
93
  eventBus: WorkflowEventBus,
90
94
  db: Database,
@@ -431,12 +435,15 @@ export function createAccessRoutes(
431
435
 
432
436
  /**
433
437
  * POST /api/workspace/:id/access/batch
434
- * Body: `{ paths: string[], verb?: 'write' | 'owner' }`. Returns
438
+ * Body: `{ paths: string[], verb?: 'write' | 'owner' | 'read' }`. Returns
435
439
  * `{ results: { [path]: boolean } }`.
436
440
  *
437
441
  * `verb` defaults to `write`, the question every existing caller asks.
438
442
  * `owner` answers from the `owner:` lists alone — no admin rescue, and a
439
- * writer is not an owner — which is what an Owner pill has to mean.
443
+ * writer is not an owner — which is what an Owner pill has to mean. `read`
444
+ * is the verdict the single lookup's `canRead` gives, for many paths at
445
+ * once: what New page asks on a draft branch, where a page may go wherever
446
+ * its author may read, of every Knowledge folder in one request.
440
447
  */
441
448
  router.post('/workspace/:id/access/batch', async (req, res) => {
442
449
  const user = await requireUser(req, res);
@@ -451,13 +458,18 @@ export function createAccessRoutes(
451
458
  res.status(400).json({ error: 'paths cannot exceed 500 entries per request' });
452
459
  return;
453
460
  }
454
- if (verb !== 'write' && verb !== 'owner') {
455
- res.status(400).json({ error: 'verb must be "write" or "owner"' });
461
+ if (verb !== 'write' && verb !== 'owner' && verb !== 'read') {
462
+ res.status(400).json({ error: 'verb must be "write", "owner" or "read"' });
456
463
  return;
457
464
  }
458
465
 
459
466
  try {
460
- const batch = verb === 'owner' ? accessControl.canOwnerBatch : accessControl.canWriteBatch;
467
+ const batch =
468
+ verb === 'owner'
469
+ ? accessControl.canOwnerBatch
470
+ : verb === 'read'
471
+ ? accessControl.canReadBatch
472
+ : accessControl.canWriteBatch;
461
473
  const result = await batch.call(
462
474
  accessControl,
463
475
  req.params.id,
@@ -1228,6 +1240,39 @@ export function createAccessRoutes(
1228
1240
  }
1229
1241
  });
1230
1242
 
1243
+ // Who the admins are, for anyone signed in: the top bar tells someone who
1244
+ // is not an admin to ask one, and names them. Names and emails ONLY — the
1245
+ // roster above (every role, its members, groups and references) stays
1246
+ // admin-only. Names come from the accounts table; an admin with no account
1247
+ // yet (a deployment admin before first sign-in, say) is named by address.
1248
+ // A role member whose account is switched off cannot sign in to invite
1249
+ // anyone, so is left out; a deployment admin is not (it signs in with the
1250
+ // environment's password regardless).
1251
+ router.get('/access/admins', async (req, res) => {
1252
+ const user = await requireUser(req, res);
1253
+ if (!user) return;
1254
+ try {
1255
+ const emails = await rolesAdmin.getAdminEmails();
1256
+ const rows =
1257
+ emails.length === 0
1258
+ ? []
1259
+ : await db
1260
+ .select({ email: users.email, name: users.name, deactivatedAt: users.deactivatedAt })
1261
+ .from(users)
1262
+ .where(inArray(users.emailBidx, emails));
1263
+ const names = new Map(rows.map((r) => [canonicalEmail(r.email), r.name?.trim() ?? '']));
1264
+ const off = new Set(rows.filter((r) => r.deactivatedAt).map((r) => canonicalEmail(r.email)));
1265
+ const admins = emails
1266
+ .filter((email) => !off.has(email) || rolesAdmin.isFixedAdmin(email))
1267
+ .map((email) => ({ name: names.get(email) || email, email }))
1268
+ .sort((a, b) => a.name.localeCompare(b.name) || a.email.localeCompare(b.email));
1269
+ res.json({ admins });
1270
+ } catch (err) {
1271
+ const { status, body } = toHttpError(err);
1272
+ res.status(status).json(body);
1273
+ }
1274
+ });
1275
+
1231
1276
  // NOTE: POST /access/roles (create), PATCH /access/roles/:canonical
1232
1277
  // (rename), and DELETE /access/roles/:canonical are GONE — roles are
1233
1278
  // app-defined capabilities, not user-editable objects.
@@ -1239,7 +1284,23 @@ export function createAccessRoutes(
1239
1284
  await assertRolesAdmin(user.email);
1240
1285
  const canonical = canonicalRoleName(req.params.canonical);
1241
1286
  const email = requireNonEmptyString((req.body ?? {}).email, 'email');
1242
- res.json({ roles: await rolesAdmin.addMember(user, canonical, email) });
1287
+ // `ifActive`: the invite's promotion, which leaves a switched-off
1288
+ // account unchanged. Inside the roles file's lock, auth reads the
1289
+ // account under the address's switch lock and keeps that transaction
1290
+ // open until roles.yaml is written and committed
1291
+ // (`IAccountSwitchGuard.whileSwitchedOn`). So a switch-off either committed
1292
+ // before the read (the promotion is refused) or waits until the
1293
+ // promotion is written (it lands after, as for any Admin switched off
1294
+ // later: the place in roles stays, the credentials stop being honoured).
1295
+ const ifActive = (req.body ?? {}).ifActive;
1296
+ if (ifActive !== undefined && typeof ifActive !== 'boolean') {
1297
+ res.status(400).json({ error: 'ifActive must be a boolean' });
1298
+ return;
1299
+ }
1300
+ const whileSwitchedOn = (write: () => Promise<void>): Promise<void> => switchGuard.whileSwitchedOn(email, write);
1301
+ res.json({
1302
+ roles: await rolesAdmin.addMember(user, canonical, email, ifActive ? { aroundWrite: whileSwitchedOn } : undefined),
1303
+ });
1243
1304
  } catch (err) {
1244
1305
  const { status, body } = toHttpError(err);
1245
1306
  res.status(status).json(body);
@@ -103,6 +103,13 @@ export interface RoleRosterEntry {
103
103
  * email once, as fixed.
104
104
  */
105
105
  fixedMembers: string[];
106
+ /**
107
+ * The note the page prints beside {@link fixedMembers}, and the sentence
108
+ * both Admin-membership refusals lead with. Sent with the roster so the
109
+ * page keeps no copy of the wording. Null when the role has no fixed
110
+ * members.
111
+ */
112
+ fixedMembersNote: string | null;
106
113
  /** Canonical names of groups this role is assigned to (`group:` members). */
107
114
  groups: string[];
108
115
  isAdmin: boolean;
@@ -165,11 +172,12 @@ export class RolesAdminService {
165
172
  private readonly fixedAdminEmails: readonly string[];
166
173
  /**
167
174
  * The one explanation both Admin-role refusals carry, verbatim: the note the
168
- * page prints beside a fixed member. Kept as a named constant so the refusal
169
- * an API caller reads and the note a UI reader sees cannot drift apart.
175
+ * page prints beside a fixed member, which it reads from the roster's
176
+ * `fixedMembersNote`. One constant, so the refusal an API caller reads and
177
+ * the note a UI reader sees cannot drift apart.
170
178
  */
171
179
  private static readonly FIXED_ADMIN_NOTE =
172
- 'Deployment admin, set in the server configuration; cannot be removed here';
180
+ 'Owner; cannot be removed here';
173
181
 
174
182
  constructor(
175
183
  private readonly workspaceService: IWorkspaceService,
@@ -227,9 +235,10 @@ export class RolesAdminService {
227
235
  /**
228
236
  * Is `email` a deployment admin — an address the server configuration makes
229
237
  * an Admin, ahead of `roles.yaml`? Drives both the fixed member rows and
230
- * the add/remove refusals on the Admin role.
238
+ * the add/remove refusals on the Admin role, and keeps a switched-off
239
+ * deployment admin among the admins people are told to ask.
231
240
  */
232
- private isFixedAdmin(email: string): boolean {
241
+ isFixedAdmin(email: string): boolean {
233
242
  return this.fixedAdminEmails.includes(canonicalEmail(email));
234
243
  }
235
244
 
@@ -326,6 +335,8 @@ export class RolesAdminService {
326
335
  // Only Admin has fixed members: `deploymentOwners` is an Admin rescue
327
336
  // in the resolver, never a general grant, so no other role gains one.
328
337
  fixedMembers: isAdmin ? [...this.fixedAdminEmails] : [],
338
+ fixedMembersNote:
339
+ isAdmin && this.fixedAdminEmails.length > 0 ? RolesAdminService.FIXED_ADMIN_NOTE : null,
329
340
  groups: role.members
330
341
  .filter(isGroupRefMember)
331
342
  .map((m) => canonicalRoleName(m.slice(GROUP_REF_PREFIX.length))),
@@ -342,6 +353,18 @@ export class RolesAdminService {
342
353
  return out;
343
354
  }
344
355
 
356
+ /**
357
+ * The addresses the app treats as an Admin, each once — the resolver's own
358
+ * membership answer ({@link IAccessControl.adminEmails}), so who this names
359
+ * and who the admin gates admit cannot drift apart. Nothing else about
360
+ * roles: this backs a read any signed-in person may make (who to ask for an
361
+ * invite), unlike the roster.
362
+ */
363
+ async getAdminEmails(): Promise<string[]> {
364
+ const workspaceId = await this.ensureWorkspace();
365
+ return this.accessControl.adminEmails(workspaceId);
366
+ }
367
+
345
368
  /**
346
369
  * Whether the default-branch roles.yaml parses. Drives the "roles file
347
370
  * corrupted" banner. Auth-only (never admin-gated): a corrupted file resolves
@@ -446,10 +469,24 @@ export class RolesAdminService {
446
469
  * admits it ahead of `roles.yaml` — so the write would add a row that means
447
470
  * nothing, next to the fixed row the page already shows. Other roles are
448
471
  * unaffected: a deployment admin is an ordinary member of "Sales".
472
+ *
473
+ * `aroundWrite`, when given, runs once the roles file's lock is held and
474
+ * is handed the edit itself (read, check, write, commit) to run. What it
475
+ * does around that call brackets the whole write: the invite holds the
476
+ * address's account-switch lock (a transaction-scoped advisory lock keyed
477
+ * on the canonical email, see `AuthService.whileSwitchedOn`) across it, so
478
+ * a switch-off cannot commit in between, whether or not the account exists
479
+ * yet (see the members route). It may throw instead of calling `write`;
480
+ * nothing is written then.
449
481
  */
450
- async addMember(actor: AuthUser, canonical: string, email: string): Promise<RoleRosterEntry[]> {
482
+ async addMember(
483
+ actor: AuthUser,
484
+ canonical: string,
485
+ email: string,
486
+ opts?: { aroundWrite?: (write: () => Promise<void>) => Promise<void> },
487
+ ): Promise<RoleRosterEntry[]> {
451
488
  this.assertNotFixedAdmin(canonical, email);
452
- await this.runEdit(actor, (text) => editAddMember(text, canonical, email));
489
+ await this.runEdit(actor, (text) => editAddMember(text, canonical, email), opts?.aroundWrite);
453
490
  return this.getRoster();
454
491
  }
455
492
 
@@ -682,17 +719,20 @@ export class RolesAdminService {
682
719
  * `pre` produces the candidate (and may throw RolesAdminError for invariant
683
720
  * violations before any write). Skips on a no-op. Every candidate passes
684
721
  * the resolver's own parser (assertLoadable) plus the pre-disk validator
685
- * before a byte lands.
722
+ * before a byte lands. `aroundWrite` wraps the locked edit (see
723
+ * {@link addMember}).
686
724
  */
687
725
  private async runEdit(
688
726
  actor: AuthUser,
689
727
  pre: (currentText: string) => EditResult,
728
+ aroundWrite?: (write: () => Promise<void>) => Promise<void>,
690
729
  ): Promise<void> {
691
730
  const workspaceId = await this.ensureWorkspace();
692
731
  await this.assertRolesUnlocked(workspaceId, actor);
693
- return this.locked.withFileLocks(workspaceId, actor, [ROLES_YAML], () =>
694
- this.runEditLocked(workspaceId, actor, pre),
695
- );
732
+ return this.locked.withFileLocks(workspaceId, actor, [ROLES_YAML], () => {
733
+ const write = () => this.runEditLocked(workspaceId, actor, pre);
734
+ return aroundWrite ? aroundWrite(write) : write();
735
+ });
696
736
  }
697
737
 
698
738
  private async runEditLocked(
@@ -492,7 +492,7 @@ export class UserAccessRemovalService {
492
492
  /** Why `email` may not be removed from access files, or null when it may. */
493
493
  private blockedReason(rolesText: string | undefined, email: string): string | null {
494
494
  if (this.deploymentOwners.some((o) => canonicalEmail(o) === email)) {
495
- return 'This is the deployment owner (ADMIN_EMAIL); their access cannot be removed here.';
495
+ return 'The owner stays in roles and access rules.';
496
496
  }
497
497
  const admin = rolesText
498
498
  ? (() => {
@@ -1,4 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
+ import { PgDialect } from 'drizzle-orm/pg-core';
3
+ import type { SQL } from 'drizzle-orm';
2
4
  import type { Database } from '../../database/connection.js';
3
5
  import type { CoreConfig } from '../../../core-config.js';
4
6
  import { AuthService } from '../auth.service.js';
@@ -16,10 +18,19 @@ import { RECOVERY_BOT_EMAIL } from '../../workflow/recovery-bot.js';
16
18
  /**
17
19
  * Drizzle chain stub, as in `auth.service.test.ts`: each db.select() /
18
20
  * insert() / update() consumes the next queued result; awaiting any point of
19
- * the chain resolves it. Records what was inserted and set.
21
+ * the chain resolves it. Records what was inserted and set, and, in order,
22
+ * every write and lock (`execute`) with the handle it went through: `db`
23
+ * itself, or the `tx` a transaction hands its callback (a different object,
24
+ * between `begin` and `commit`), so a lock taken outside the transaction
25
+ * shows up as `db:`.
20
26
  */
21
27
  function makeFakeDb(queue: unknown[]) {
22
- const captured: { values: Record<string, unknown>[]; set: Record<string, unknown>[] } = { values: [], set: [] };
28
+ const captured: { values: Record<string, unknown>[]; set: Record<string, unknown>[]; order: string[] } = {
29
+ values: [],
30
+ set: [],
31
+ order: [],
32
+ };
33
+ const dialect = new PgDialect();
23
34
  function nextChain(): unknown {
24
35
  const result = queue.shift();
25
36
  const chain: Record<string, unknown> = {};
@@ -42,12 +53,27 @@ function makeFakeDb(queue: unknown[]) {
42
53
  });
43
54
  return chain;
44
55
  }
45
- const db = {
46
- insert: vi.fn(() => nextChain()),
56
+ const handle = (tag: 'db' | 'tx') => ({
57
+ insert: vi.fn(() => (captured.order.push(`${tag}:insert`), nextChain())),
47
58
  select: vi.fn(() => nextChain()),
48
- update: vi.fn(() => nextChain()),
49
- } as unknown as Database;
50
- return { db, captured };
59
+ update: vi.fn(() => (captured.order.push(`${tag}:update`), nextChain())),
60
+ execute: vi.fn(async (query: SQL) => {
61
+ const { sql, params } = dialect.sqlToQuery(query);
62
+ captured.order.push(`${tag}:${sql} ${JSON.stringify(params)}`);
63
+ return [];
64
+ }),
65
+ });
66
+ const tx = handle('tx');
67
+ const db = {
68
+ ...handle('db'),
69
+ transaction: async <T,>(cb: (t: typeof tx) => Promise<T>) => {
70
+ captured.order.push('begin');
71
+ const out = await cb(tx);
72
+ captured.order.push('commit');
73
+ return out;
74
+ },
75
+ };
76
+ return { db: db as unknown as Database, captured };
51
77
  }
52
78
 
53
79
  function makeConfig(over: Partial<CoreConfig> = {}): CoreConfig {
@@ -156,6 +182,64 @@ describe('AuthService.isActive', () => {
156
182
  });
157
183
  });
158
184
 
185
+ // An earlier version could switch off an owner without a server-held
186
+ // password; nothing in the product can switch them back on for a workspace
187
+ // whose only admin they are. Their way in clears it.
188
+ describe('an owner switched off by an earlier version', () => {
189
+ const OWNER_OFF = { ...OFF, id: 'owner-1', email: 'root@example.com' };
190
+
191
+ for (const [label, adminPassword] of [
192
+ ['with ADMIN_PASSWORD', 'sup3r-secret'],
193
+ ['without ADMIN_PASSWORD (the cloud)', ''],
194
+ ] as const) {
195
+ const config = makeConfig({ adminEmail: 'root@example.com', adminPassword });
196
+
197
+ it(`signs in with single sign-on, and the switch-off is cleared on the row, ${label}`, async () => {
198
+ const { db, captured } = makeFakeDb([[OWNER_OFF], []]);
199
+ const { user } = await new AuthService(db, config).loginWithSso('root@example.com', 'Root');
200
+ expect(user.id).toBe('owner-1');
201
+ expect(captured.set).toEqual([expect.objectContaining({ deactivatedAt: null })]);
202
+ });
203
+
204
+ it(`is let in as an embed identity, ${label}`, async () => {
205
+ const { db, captured } = makeFakeDb([[OWNER_OFF], []]);
206
+ await expect(new AuthService(db, config).getOrCreateByEmail('root@example.com')).resolves.toMatchObject({ id: 'owner-1' });
207
+ expect(captured.set).toEqual([expect.objectContaining({ deactivatedAt: null })]);
208
+ });
209
+
210
+ it(`keeps a session they already hold, ${label}`, async () => {
211
+ const { db, captured } = makeFakeDb([[{ id: 'owner-1', email: 'root@example.com', deactivatedAt: new Date() }], []]);
212
+ expect(await new AuthService(db, config).isActive('owner-1')).toBe(true);
213
+ expect(captured.set).toEqual([expect.objectContaining({ deactivatedAt: null })]);
214
+ });
215
+ }
216
+
217
+ it('signs in with a password they hold on the row, without ADMIN_PASSWORD', async () => {
218
+ const passwordHash = await hashPassword('a-long-enough-password');
219
+ const { db, captured } = makeFakeDb([[{ ...OWNER_OFF, passwordHash }], []]);
220
+ const svc = new AuthService(db, makeConfig({ adminEmail: 'root@example.com' }));
221
+ const { user } = await svc.loginWithPassword('root@example.com', 'a-long-enough-password');
222
+ expect(user.id).toBe('owner-1');
223
+ expect(captured.set).toEqual([expect.objectContaining({ deactivatedAt: null })]);
224
+ });
225
+
226
+ it('signs in with ADMIN_PASSWORD, and the switch-off is cleared on the row', async () => {
227
+ // The lookup, the upsert, then the clearing update.
228
+ const { db, captured } = makeFakeDb([[OWNER_OFF], [OWNER_OFF], []]);
229
+ const svc = new AuthService(db, makeConfig({ adminEmail: 'root@example.com', adminPassword: 'sup3r-secret' }));
230
+ const { user } = await svc.loginWithPassword('root@example.com', 'sup3r-secret');
231
+ expect(user.id).toBe('owner-1');
232
+ expect(captured.set).toEqual([expect.objectContaining({ deactivatedAt: null })]);
233
+ });
234
+
235
+ it('leaves a switched-off account that is not an owner switched off', async () => {
236
+ const { db, captured } = makeFakeDb([[OFF]]);
237
+ const svc = new AuthService(db, makeConfig({ adminEmail: 'root@example.com' }));
238
+ await expect(svc.loginWithSso('alice@example.com', 'Alice')).rejects.toBeInstanceOf(AccountDeactivatedError);
239
+ expect(captured.set).toEqual([]);
240
+ });
241
+ });
242
+
159
243
  describe('AuthService.deactivate', () => {
160
244
  it('records when the account was switched off', async () => {
161
245
  const { db, captured } = makeFakeDb([[ROW], []]);
@@ -163,22 +247,59 @@ describe('AuthService.deactivate', () => {
163
247
  expect(captured.set[0].deactivatedAt).toBeInstanceOf(Date);
164
248
  });
165
249
 
250
+ it('switches off under the address switch lock, taken before the update', async () => {
251
+ const { db, captured } = makeFakeDb([[{ ...ROW, email: 'Alice@Example.com' }], []]);
252
+ await new AuthService(db, makeConfig()).deactivate(ROW.id);
253
+ expect(captured.order).toEqual([
254
+ 'begin',
255
+ // The wait for the lock is bounded, inside the transaction.
256
+ `tx:select set_config('lock_timeout', $1, true) ["15000"]`,
257
+ 'tx:select pg_advisory_xact_lock(hashtext($1)) ["account-switch:alice@example.com"]',
258
+ 'tx:update',
259
+ 'commit',
260
+ ]);
261
+ });
262
+
166
263
  it('answers false for an account that does not exist', async () => {
167
264
  const { db } = makeFakeDb([[]]);
168
265
  expect(await new AuthService(db, makeConfig()).deactivate('nobody')).toBe(false);
169
266
  });
170
267
 
171
- it('refuses the deployment admin, whose environment password is the way back in', async () => {
172
- const config = makeConfig({ adminEmail: 'root@example.com', adminPassword: 'sup3r-secret' });
173
- const { db } = makeFakeDb([[{ ...ROW, email: 'root@example.com' }]]);
174
- await expect(new AuthService(db, config).deactivate(ROW.id)).rejects.toBeInstanceOf(AccountChangeRefusedError);
175
- expect(vi.mocked(db.update)).not.toHaveBeenCalled();
268
+ // With a server-held owner password (self-hosted) and without one (the
269
+ // cloud's configuration): the owner is never switched off, and the refusal
270
+ // comes before any lock or write (`captured.order` stays empty).
271
+ for (const [label, adminPassword] of [
272
+ ['with ADMIN_PASSWORD', 'sup3r-secret'],
273
+ ['without ADMIN_PASSWORD (the cloud)', ''],
274
+ ] as const) {
275
+ it(`refuses the owner, ${label}, and says why`, async () => {
276
+ const config = makeConfig({ adminEmail: 'root@example.com', adminPassword });
277
+ const { db, captured } = makeFakeDb([[{ ...ROW, email: 'root@example.com' }]]);
278
+ const refusal = new AuthService(db, config).deactivate(ROW.id);
279
+ await expect(refusal).rejects.toBeInstanceOf(AccountChangeRefusedError);
280
+ await expect(refusal).rejects.toThrow("The owner can't be switched off.");
281
+ expect(captured.order).toEqual([]);
282
+ });
283
+
284
+ it(`still switches off an account that is not the owner, ${label}`, async () => {
285
+ const config = makeConfig({ adminEmail: 'root@example.com', adminPassword });
286
+ const { db, captured } = makeFakeDb([[ROW], []]);
287
+ expect(await new AuthService(db, config).deactivate(ROW.id)).toBe(true);
288
+ expect(captured.set[0].deactivatedAt).toBeInstanceOf(Date);
289
+ });
290
+ }
291
+
292
+ it('refuses the owner even with password sign-in switched off', async () => {
293
+ const config = makeConfig({ adminEmail: 'root@example.com', adminPassword: 'sup3r-secret', loginPasswordEnabled: false });
294
+ const { db, captured } = makeFakeDb([[{ ...ROW, email: 'root@example.com' }]]);
295
+ await expect(new AuthService(db, config).deactivate(ROW.id)).rejects.toThrow("The owner can't be switched off.");
296
+ expect(captured.order).toEqual([]);
176
297
  });
177
298
 
178
299
  it('refuses the accounts the platform runs its own work as', async () => {
179
- const { db } = makeFakeDb([[{ ...ROW, email: RECOVERY_BOT_EMAIL }]]);
300
+ const { db, captured } = makeFakeDb([[{ ...ROW, email: RECOVERY_BOT_EMAIL }]]);
180
301
  await expect(new AuthService(db, makeConfig()).deactivate(ROW.id)).rejects.toThrow('platform itself');
181
- expect(vi.mocked(db.update)).not.toHaveBeenCalled();
302
+ expect(captured.order).toEqual([]);
182
303
  });
183
304
  });
184
305
 
@@ -221,17 +342,25 @@ describe('a first sign-in the port would rather keep waiting', () => {
221
342
  expect(captured.values).toHaveLength(1);
222
343
  expect(captured.values[0]).toMatchObject({ email: 'new@example.com' });
223
344
  expect(captured.values[0].deactivatedAt).toBeInstanceOf(Date);
345
+ // Created switched off, so under the address's switch lock.
346
+ expect(captured.order).toEqual([
347
+ 'begin',
348
+ `tx:select set_config('lock_timeout', $1, true) ["15000"]`,
349
+ 'tx:select pg_advisory_xact_lock(hashtext($1)) ["account-switch:new@example.com"]',
350
+ 'tx:insert',
351
+ 'commit',
352
+ ]);
224
353
  });
225
354
 
226
355
  it('is a plain refusal when an admin creates the account', async () => {
227
356
  const { port } = recordingPort(waiting);
228
- const { db } = makeFakeDb([[]]);
357
+ const { db, captured } = makeFakeDb([[]]);
229
358
  const err = await new AuthService(db, makeConfig(), port)
230
359
  .createAccount('new@example.com', 'New')
231
360
  .catch((e: unknown) => e);
232
361
  expect(err).toBeInstanceOf(AccountAdmissionRefusedError);
233
362
  expect((err as AccountAdmissionRefusedError).waitingForAdmin).toBe(false);
234
- expect(vi.mocked(db.insert)).not.toHaveBeenCalled();
363
+ expect(captured.order).toEqual([]);
235
364
  });
236
365
  });
237
366
 
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { AccountHooks, type AccountInvitedEvent } from '../account-hooks.js';
3
+
4
+ const EVENT: AccountInvitedEvent = {
5
+ account: { id: 'u-lena', email: 'lena@acme.test', name: 'lena' },
6
+ invitedBy: { id: 'u-dana', email: 'dana@acme.test', name: 'Dana Admin' },
7
+ passwordSet: true,
8
+ };
9
+
10
+ describe('AccountHooks: an account was invited', () => {
11
+ it('notifies nobody when nothing is registered, and says so', async () => {
12
+ expect(await new AccountHooks().runAccountInvited(EVENT)).toEqual([]);
13
+ });
14
+
15
+ it('runs every listener in registration order, with the event, and collects the channels that reached the person', async () => {
16
+ const hooks = new AccountHooks();
17
+ const seen: string[] = [];
18
+ hooks.onAccountInvited(async (event) => {
19
+ seen.push(`mail:${event.account.email}:${event.passwordSet}`);
20
+ return 'email';
21
+ });
22
+ hooks.onAccountInvited(async () => {
23
+ seen.push('chat');
24
+ return null;
25
+ });
26
+ hooks.onAccountInvited(async () => {
27
+ seen.push('sms');
28
+ return 'sms';
29
+ });
30
+ expect(await hooks.runAccountInvited(EVENT)).toEqual(['email', 'sms']);
31
+ expect(seen).toEqual(['mail:lena@acme.test:true', 'chat', 'sms']);
32
+ });
33
+
34
+ it('a listener that throws counts as not having notified, and stops nothing', async () => {
35
+ const hooks = new AccountHooks();
36
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
37
+ try {
38
+ hooks.onAccountInvited(async () => {
39
+ throw new Error('smtp down');
40
+ });
41
+ hooks.onAccountInvited(async () => 'email');
42
+ expect(await hooks.runAccountInvited(EVENT)).toEqual(['email']);
43
+ // Logged, so a failing mailer is diagnosable from the server's log.
44
+ expect(error).toHaveBeenCalledTimes(1);
45
+ expect(String(error.mock.calls[0]![0])).toContain('an account-invited listener failed');
46
+ } finally {
47
+ error.mockRestore();
48
+ }
49
+ });
50
+
51
+ it('an empty channel name is no channel: it counts as not having notified', async () => {
52
+ const hooks = new AccountHooks();
53
+ hooks.onAccountInvited(async () => '');
54
+ hooks.onAccountInvited(async () => 'email');
55
+ expect(await hooks.runAccountInvited(EVENT)).toEqual(['email']);
56
+ });
57
+ });
@@ -0,0 +1,78 @@
1
+ import { afterAll, describe, expect, it } from 'vitest';
2
+ import { randomBytes } from 'node:crypto';
3
+ import pg from 'pg';
4
+ import { closeDb, createDb, type Database } from '../../database/connection.js';
5
+ import { runCoreMigrations } from '../../database/migrate.js';
6
+ import { coreMigrationsDir } from '../../../assets.js';
7
+ import { PG_ADMIN_URL, PG_TEST_TIMEOUT, dropScratchDatabases, scratchDatabaseUrl } from '../../../__tests__/pg-scratch.js';
8
+ import { WorkflowDomainError } from '../../../shared/domain-errors.js';
9
+ import { AuthService } from '../auth.service.js';
10
+ import { ACCOUNT_BUSY_MESSAGE, takeAccountSwitchLock } from '../account-switch-lock.js';
11
+
12
+ /**
13
+ * The account switch lock's promise, against a real Postgres (see
14
+ * `pg-scratch.ts`): a wait that runs out is a 409 `account-busy`, never the
15
+ * driver's error. Drizzle wraps that error (the SQLSTATE `55P03` is on its
16
+ * cause), which no stub of the driver shows, so the contention is real here:
17
+ * another connection holds the address's lock while the lock is asked for.
18
+ */
19
+ const KEY = randomBytes(32).toString('base64');
20
+ const open: Database[] = [];
21
+ const holders: pg.Client[] = [];
22
+
23
+ async function migratedDatabase(): Promise<{ db: Database; url: string }> {
24
+ const url = await scratchDatabaseUrl('hexis_switch');
25
+ const db = createDb(url, { piiKey: KEY });
26
+ open.push(db);
27
+ await runCoreMigrations(db, coreMigrationsDir());
28
+ return { db, url };
29
+ }
30
+
31
+ /** Another connection holding `email`'s switch lock in an open transaction, as a promotion mid-write does. */
32
+ async function holdLock(url: string, email: string): Promise<pg.Client> {
33
+ const holder = new pg.Client({ connectionString: url });
34
+ await holder.connect();
35
+ holders.push(holder);
36
+ await holder.query('begin');
37
+ await holder.query('select pg_advisory_xact_lock(hashtext($1))', [`account-switch:${email}`]);
38
+ return holder;
39
+ }
40
+
41
+ describe.skipIf(!PG_ADMIN_URL)('the account switch lock under contention, on a real Postgres', () => {
42
+ afterAll(async () => {
43
+ for (const holder of holders) await holder.end().catch(() => undefined);
44
+ for (const db of open) await closeDb(db);
45
+ await dropScratchDatabases();
46
+ }, PG_TEST_TIMEOUT);
47
+
48
+ it(
49
+ "answers a wait that runs out as the 409 account-busy, through Drizzle's wrapping",
50
+ async () => {
51
+ const { db, url } = await migratedDatabase();
52
+ const holder = await holdLock(url, 'alice@acme.test');
53
+ const refusal = db.transaction((tx) => takeAccountSwitchLock(tx, 'Alice@Acme.test', 200));
54
+ await expect(refusal).rejects.toBeInstanceOf(WorkflowDomainError);
55
+ await expect(refusal).rejects.toMatchObject({ status: 409, message: ACCOUNT_BUSY_MESSAGE, payload: { kind: 'account-busy' } });
56
+ // Released, the lock is taken at once.
57
+ await holder.query('commit');
58
+ await expect(db.transaction((tx) => takeAccountSwitchLock(tx, 'alice@acme.test', 200))).resolves.toBeUndefined();
59
+ },
60
+ PG_TEST_TIMEOUT,
61
+ );
62
+
63
+ it(
64
+ 'switching an account off while a promotion holds its lock is refused as account-busy, and changes nothing',
65
+ async () => {
66
+ const { db, url } = await migratedDatabase();
67
+ const auth = new AuthService(db, { jwtSecret: 'test-jwt-secret', adminEmail: '', adminPassword: '', allowedEmailDomains: [] });
68
+ const { user } = await auth.createAccountWithoutPassword('lee@acme.test', 'Lee');
69
+ const holder = await holdLock(url, 'lee@acme.test');
70
+ // The real wait (15 s): what an admin's switch-off meets.
71
+ await expect(auth.deactivate(user.id)).rejects.toMatchObject({ status: 409, payload: { kind: 'account-busy' } });
72
+ await holder.query('commit');
73
+ const [lee] = (await auth.listAccounts()).filter((a) => a.email === 'lee@acme.test');
74
+ expect(lee?.deactivatedAt).toBeNull();
75
+ },
76
+ PG_TEST_TIMEOUT,
77
+ );
78
+ });
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { PgDialect } from 'drizzle-orm/pg-core';
3
+ import type { SQL } from 'drizzle-orm';
4
+ import { WorkflowDomainError } from '../../../shared/domain-errors.js';
5
+ import { ACCOUNT_BUSY_MESSAGE, ACCOUNT_SWITCH_LOCK_WAIT_MS, takeAccountSwitchLock } from '../account-switch-lock.js';
6
+
7
+ /** A transaction handle that records what it ran, and fails the lock statement when told to. */
8
+ function fakeTx(failLockWith?: { code: string; message: string }) {
9
+ const dialect = new PgDialect();
10
+ const ran: string[] = [];
11
+ const execute = vi.fn(async (query: SQL) => {
12
+ const { sql, params } = dialect.sqlToQuery(query);
13
+ ran.push(`${sql} ${JSON.stringify(params)}`);
14
+ if (failLockWith && sql.includes('pg_advisory_xact_lock')) {
15
+ throw Object.assign(new Error(failLockWith.message), { code: failLockWith.code });
16
+ }
17
+ return [];
18
+ });
19
+ return { tx: { execute }, ran };
20
+ }
21
+
22
+ describe('takeAccountSwitchLock', () => {
23
+ it('bounds the wait with a transaction-local lock_timeout, set before the lock is asked for', async () => {
24
+ const { tx, ran } = fakeTx();
25
+ await takeAccountSwitchLock(tx, 'Alice@Example.com');
26
+ expect(ran).toEqual([
27
+ `select set_config('lock_timeout', $1, true) ["${ACCOUNT_SWITCH_LOCK_WAIT_MS}"]`,
28
+ 'select pg_advisory_xact_lock(hashtext($1)) ["account-switch:alice@example.com"]',
29
+ ]);
30
+ });
31
+
32
+ it('answers a wait that ran out as a 409 account-busy, in the words the admin is told', async () => {
33
+ const { tx } = fakeTx({ code: '55P03', message: 'canceling statement due to lock timeout' });
34
+ const refusal = takeAccountSwitchLock(tx, 'alice@example.com');
35
+ await expect(refusal).rejects.toBeInstanceOf(WorkflowDomainError);
36
+ await expect(refusal).rejects.toMatchObject({ status: 409, message: ACCOUNT_BUSY_MESSAGE, payload: { kind: 'account-busy' } });
37
+ });
38
+
39
+ it('lets any other failure of the lock through unchanged', async () => {
40
+ const { tx } = fakeTx({ code: '57P01', message: 'terminating connection due to administrator command' });
41
+ await expect(takeAccountSwitchLock(tx, 'alice@example.com')).rejects.toThrow('terminating connection');
42
+ });
43
+ });