@bevel-software/platform-core-backend 0.31.0 → 0.32.0

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 (76) hide show
  1. package/dist/core/create-core-services.d.ts +7 -0
  2. package/dist/core/create-core-services.d.ts.map +1 -1
  3. package/dist/core/create-core-services.js +12 -1
  4. package/dist/core/create-core-services.js.map +1 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/modules/access-model/index.d.ts +14 -0
  10. package/dist/modules/access-model/index.d.ts.map +1 -0
  11. package/dist/modules/access-model/index.js +13 -0
  12. package/dist/modules/access-model/index.js.map +1 -0
  13. package/dist/modules/auth/account-hooks.d.ts +4 -6
  14. package/dist/modules/auth/account-hooks.d.ts.map +1 -1
  15. package/dist/modules/auth/account-hooks.js +7 -17
  16. package/dist/modules/auth/account-hooks.js.map +1 -1
  17. package/dist/modules/auth/auth.service.d.ts +9 -8
  18. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  19. package/dist/modules/auth/auth.service.js +19 -18
  20. package/dist/modules/auth/auth.service.js.map +1 -1
  21. package/dist/modules/database/core-schema.d.ts +21 -1
  22. package/dist/modules/database/core-schema.d.ts.map +1 -1
  23. package/dist/modules/database/core-schema.js +16 -1
  24. package/dist/modules/database/core-schema.js.map +1 -1
  25. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +28 -0
  26. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  27. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +166 -39
  28. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  29. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +10 -1
  30. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -1
  31. package/dist/modules/workflow/agent-tools/change-request-summary.js +2 -0
  32. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -1
  33. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  34. package/dist/modules/workflow/agent-tools/workflow.tools.js +5 -0
  35. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  36. package/dist/modules/workflow/change-request-hooks.d.ts +78 -0
  37. package/dist/modules/workflow/change-request-hooks.d.ts.map +1 -0
  38. package/dist/modules/workflow/change-request-hooks.js +15 -0
  39. package/dist/modules/workflow/change-request-hooks.js.map +1 -0
  40. package/dist/modules/workflow/git/pull-request.service.d.ts +10 -0
  41. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  42. package/dist/modules/workflow/git/pull-request.service.js +9 -0
  43. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  44. package/dist/modules/workflow/workflow.service.d.ts +75 -15
  45. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  46. package/dist/modules/workflow/workflow.service.js +163 -32
  47. package/dist/modules/workflow/workflow.service.js.map +1 -1
  48. package/dist/shared/listener-seam.d.ts +34 -0
  49. package/dist/shared/listener-seam.d.ts.map +1 -0
  50. package/dist/shared/listener-seam.js +42 -0
  51. package/dist/shared/listener-seam.js.map +1 -0
  52. package/migrations/0019_oauth_token_rotated_at.sql +7 -0
  53. package/migrations/meta/0019_snapshot.json +2435 -0
  54. package/migrations/meta/_journal.json +7 -0
  55. package/package.json +3 -3
  56. package/src/core/create-core-services.ts +17 -0
  57. package/src/index.ts +7 -0
  58. package/src/modules/access-model/index.ts +13 -0
  59. package/src/modules/auth/__tests__/account-deactivation.test.ts +38 -2
  60. package/src/modules/auth/account-hooks.ts +13 -21
  61. package/src/modules/auth/auth.service.ts +19 -18
  62. package/src/modules/database/core-schema.ts +16 -1
  63. package/src/modules/mcp/__tests__/bevel-oauth-provider.pg.test.ts +279 -0
  64. package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +190 -15
  65. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +214 -48
  66. package/src/modules/workflow/__tests__/change-request-hooks.test.ts +62 -0
  67. package/src/modules/workflow/__tests__/workflow.routes.open-change-request.test.ts +79 -0
  68. package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +9 -1
  69. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +258 -1
  70. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +10 -0
  71. package/src/modules/workflow/agent-tools/change-request-summary.ts +10 -1
  72. package/src/modules/workflow/agent-tools/workflow.tools.ts +6 -0
  73. package/src/modules/workflow/change-request-hooks.ts +96 -0
  74. package/src/modules/workflow/git/pull-request.service.ts +10 -0
  75. package/src/modules/workflow/workflow.service.ts +188 -40
  76. package/src/shared/listener-seam.ts +52 -0
@@ -89,6 +89,11 @@ import type { WorkflowEventBus } from './event-bus.js';
89
89
  import { describeSyncFailure, sanitizeError } from './sanitize-error.js';
90
90
  import type { FileChangeNotifier } from '../kb-fs/file-change-notifier.js';
91
91
  import { WorkflowHooks } from './workflow-hooks.js';
92
+ import {
93
+ ChangeRequestHooks,
94
+ type ChangeRequestPerson,
95
+ type IChangeRequestHooks,
96
+ } from './change-request-hooks.js';
92
97
  import { WorkspaceMutex } from '../kb-fs/mutex.js';
93
98
  import { assertValidBranchName } from '../kb-fs/branch-name.js';
94
99
  import { canonicalEmail, hashEmail } from '../../shared/email-identity.js';
@@ -108,8 +113,7 @@ import {
108
113
  WorkflowValidationError,
109
114
  } from '../../shared/domain-errors.js';
110
115
  import { RECOVERY_BOT_EMAIL, RECOVERY_BOT_NAME } from './recovery-bot.js';
111
- import { AccessDeniedError } from '../access-model/access-errors.js';
112
- import type { IChangeReadGate } from '../access-model/change-gate.js';
116
+ import { AccessDeniedError, EVERYONE_CANONICAL, type IChangeReadGate } from '../access-model/index.js';
113
117
  import { printable } from '../../shared/printable.js';
114
118
 
115
119
  const execFileAsync = promisify(execFile);
@@ -249,6 +253,34 @@ export function formatAffectedOwnersBlock(
249
253
  return out.join('\n');
250
254
  }
251
255
 
256
+ /** What the access layer answers for the changed paths of a request, or null when it cannot. */
257
+ type EligibleWritersAtRef = Awaited<ReturnType<IAccessControl['eligibleWritersForPathsAtRef']>>;
258
+
259
+ /**
260
+ * The `## Affected owners` markdown block appended to the CR body, from the
261
+ * eligibility already resolved. The per-path list is capped (see
262
+ * {@link formatAffectedOwnersBlock}) so the body stays within the
263
+ * `gh pr create --body` arg limit on large changesets. The empty string when
264
+ * nothing was resolvable — keeps the body clean rather than printing a stub
265
+ * heading.
266
+ */
267
+ function buildAffectedOwnersBlock(paths: string[], resolved: EligibleWritersAtRef): string {
268
+ if (!resolved || resolved.size === 0) return '';
269
+ return formatAffectedOwnersBlock(paths, resolved);
270
+ }
271
+
272
+ /**
273
+ * The one question the open asks of accounts: which of these addresses have
274
+ * a live account. `AuthService.findActiveAccountsByEmail` answers it in a
275
+ * single indexed read; the shape is spelled here so the workflow module
276
+ * imports nothing of auth's. Keyed by the canonical address asked for.
277
+ */
278
+ export interface ApproverAccounts {
279
+ findActiveAccountsByEmail(
280
+ emails: readonly string[],
281
+ ): Promise<Map<string, { id: string; email: string; name: string }>>;
282
+ }
283
+
252
284
  /** Git commit subjects are capped at 200 characters (`GitService.commitFile`). */
253
285
  const MAX_COMMIT_SUBJECT = 200;
254
286
 
@@ -343,6 +375,19 @@ export class WorkflowService implements IWorkflowService {
343
375
  * hooks) unchanged.
344
376
  */
345
377
  public readonly hooks: WorkflowHooks = new WorkflowHooks(),
378
+ /**
379
+ * The seam a deployment's notification extends when a request is opened
380
+ * (see `change-request-hooks.ts`). The composition root passes the SAME
381
+ * instance it exposes as `changeRequestHooks`; the default registers
382
+ * nobody, so a test construction notifies nobody and says so.
383
+ */
384
+ private readonly changeRequestHooks: IChangeRequestHooks = new ChangeRequestHooks(),
385
+ /**
386
+ * Who turns the addresses the access model names into accounts, for the
387
+ * opened event's `approvers`. Absent (a harness without accounts), every
388
+ * approver counts as unresolved.
389
+ */
390
+ private readonly accounts?: ApproverAccounts,
346
391
  ) {
347
392
  // The type already requires it; this holds for a caller the compiler did
348
393
  // not see (plain JavaScript, a positional slip in a harness).
@@ -2270,12 +2315,16 @@ export class WorkflowService implements IWorkflowService {
2270
2315
  * account, so no hidden body marker). The DB-assigned number is the CR id.
2271
2316
  * 6. Re-fetch the full detail via the PR service so the response shape
2272
2317
  * matches every other CR endpoint.
2318
+ * 7. Tell the deployment's change-request listeners (see
2319
+ * `change-request-hooks.ts`); `notified` in the answer says which
2320
+ * channels reached the approvers. Once per request, here, whether a
2321
+ * person or an agent opened it — both take this road.
2273
2322
  */
2274
2323
  async openChangeRequest(
2275
2324
  workspaceId: string,
2276
2325
  user: AuthUser,
2277
2326
  input: OpenChangeRequestInput,
2278
- ): Promise<ChangeRequestDetail> {
2327
+ ): Promise<ChangeRequestDetail & { notified: string[] }> {
2279
2328
  if (!input.sourceBranch) throw new WorkflowValidationError('sourceBranch is required');
2280
2329
  if (!input.targetBranch) throw new WorkflowValidationError('targetBranch is required');
2281
2330
  if (input.sourceBranch === input.targetBranch) {
@@ -2300,7 +2349,19 @@ export class WorkflowService implements IWorkflowService {
2300
2349
  // its branches is retired inside the window, and holding only the source
2301
2350
  // left a `Y -> X` request open to X being deleted under it — the very
2302
2351
  // failure the widened guards exist to prevent.
2303
- return this.runOnBranchPair(input.sourceBranch, input.targetBranch, async () => {
2352
+ //
2353
+ // The deployment's listeners are told AFTER the locks are released: they
2354
+ // are awaited (the answer's `notified` is true to what happened), and a
2355
+ // slow mailer must hold up this answer alone, never a delete or a merge
2356
+ // on either branch. So the locked part answers what it made — the detail,
2357
+ // or the refusal — and the telling follows.
2358
+ const outcome = await this.runOnBranchPair(input.sourceBranch, input.targetBranch, async (): Promise<
2359
+ { number: number; eligible: EligibleWritersAtRef } & (
2360
+ | { detail: ChangeRequestDetail; pushRefused: null; detailError: null }
2361
+ | { detail: null; pushRefused: PushNeedsAgentResolutionError; detailError: null }
2362
+ | { detail: null; pushRefused: null; detailError: Error }
2363
+ )
2364
+ > => {
2304
2365
  // Uniqueness rule — A→B blocks A→B (the spec is explicit that B→A in
2305
2366
  // parallel is still allowed). `listOpenPrs` is cached for 30s so we
2306
2367
  // force-fresh to catch a CR that opened just now.
@@ -2366,11 +2427,10 @@ export class WorkflowService implements IWorkflowService {
2366
2427
  input.targetBranch,
2367
2428
  input.sourceBranch,
2368
2429
  );
2369
- const affectedOwnersBlock = await this.buildAffectedOwnersBlock(
2370
- workspaceId,
2371
- input.targetBranch,
2372
- touchedPaths,
2373
- );
2430
+ // Resolved once: the body lists this eligibility, and the opened event
2431
+ // hands the same people to the deployment's listeners.
2432
+ const eligible = await this.eligibleWritersFor(workspaceId, input.targetBranch, touchedPaths);
2433
+ const affectedOwnersBlock = buildAffectedOwnersBlock(touchedPaths, eligible);
2374
2434
 
2375
2435
  // The change request is a plain DB row now — no provider PR. Author is a
2376
2436
  // real column (no hidden body marker needed, since nothing runs as a shared
@@ -2453,22 +2513,117 @@ export class WorkflowService implements IWorkflowService {
2453
2513
  // refused push never moved — answering the refusal is what the caller
2454
2514
  // needs, not a detail read that may fail on a branch the host lacks.
2455
2515
  this.events?.emit(opened);
2456
- throw pushRefused;
2516
+ return { number: prNumber, eligible, detail: null, pushRefused, detailError: null };
2457
2517
  }
2458
2518
 
2459
- const detail = await this.prs.getPrDetail(prNumber, {
2460
- fresh: true,
2461
- workspaceId,
2462
- viewerEmail: user.email,
2463
- });
2464
- if (!detail) {
2465
- throw new WorkflowValidationError(
2466
- `Created change request #${prNumber} but could not fetch its detail`,
2467
- );
2468
- }
2519
+ // The row exists and the push landed: every tab is told, whatever the
2520
+ // read-back below does.
2469
2521
  this.events?.emit(opened);
2470
- return detail;
2522
+ // Reading the detail back is the caller's answer, not the open: a read
2523
+ // that fails is reported, but it is not allowed to cost the reviewers
2524
+ // their message — the request stands, and a retry of the open meets the
2525
+ // duplicate check, so there is no second chance to tell them.
2526
+ let detail: ChangeRequestDetail | null = null;
2527
+ let detailError: Error | null = null;
2528
+ try {
2529
+ detail = await this.prs.getPrDetail(prNumber, {
2530
+ fresh: true,
2531
+ workspaceId,
2532
+ viewerEmail: user.email,
2533
+ });
2534
+ } catch (err) {
2535
+ detailError = err instanceof Error ? err : new Error(String(err));
2536
+ }
2537
+ if (detail) return { number: prNumber, eligible, detail, pushRefused: null, detailError: null };
2538
+ return {
2539
+ number: prNumber,
2540
+ eligible,
2541
+ detail: null,
2542
+ pushRefused: null,
2543
+ detailError:
2544
+ detailError ?? new WorkflowValidationError(`Created change request #${prNumber} but could not fetch its detail`),
2545
+ };
2471
2546
  });
2547
+
2548
+ // The request exists in every branch of the outcome — the listeners are
2549
+ // told all the same when the push was refused (`pushPending` says so:
2550
+ // the request is there to find, its commits reach the host with the
2551
+ // branch's next push, and what that is worth to a reviewer is each
2552
+ // listener's call) and when its detail could not be read back. Only
2553
+ // then is the refusal or the failed read the caller's answer.
2554
+ const notified = await this.notifyOpened(outcome.number, input, user, outcome.eligible, outcome.pushRefused !== null);
2555
+ if (outcome.pushRefused) throw outcome.pushRefused;
2556
+ if (outcome.detailError) throw outcome.detailError;
2557
+ return { ...outcome.detail, notified };
2558
+ }
2559
+
2560
+ /**
2561
+ * Tell the deployment's listeners the request was opened (see
2562
+ * {@link IChangeRequestHooks}); the channels that reached the approvers.
2563
+ * Never throws: a listener's failure is the hooks' to log, and a failed
2564
+ * read of the approvers' accounts hands the listeners none of them, counted
2565
+ * as unresolved.
2566
+ */
2567
+ private async notifyOpened(
2568
+ number: number,
2569
+ input: OpenChangeRequestInput,
2570
+ user: AuthUser,
2571
+ eligible: EligibleWritersAtRef,
2572
+ pushPending: boolean,
2573
+ ): Promise<string[]> {
2574
+ const { approvers, unresolvedApprovers } = await this.resolveApprovers(eligible);
2575
+ // Only an address a person can open from outside is handed over; the
2576
+ // relative in-app path (no `PUBLIC_FRONTEND_URL`) is the listener's to
2577
+ // complete.
2578
+ const link = this.prs.linkFor(number);
2579
+ return this.changeRequestHooks.runChangeRequestOpened({
2580
+ number,
2581
+ title: input.title.trim(),
2582
+ sourceBranch: input.sourceBranch,
2583
+ targetBranch: input.targetBranch,
2584
+ openedBy: { id: user.id, email: user.email, name: user.name },
2585
+ approvers,
2586
+ unresolvedApprovers,
2587
+ ...(link.urlNote === undefined ? { url: link.url } : {}),
2588
+ pushPending,
2589
+ });
2590
+ }
2591
+
2592
+ /**
2593
+ * The distinct people eligible to approve some changed path, as accounts.
2594
+ * Each path's eligibility already expands its roles and groups to the
2595
+ * addresses the access model names (`emails`), so a person is one address
2596
+ * however many paths or roles name them; the addresses are looked up as
2597
+ * live accounts in one read. An address with no account, or with one
2598
+ * switched off, and the built-in `everyone` (anyone signed in may approve;
2599
+ * nobody can be listed for that), are counted, not handed over.
2600
+ */
2601
+ private async resolveApprovers(
2602
+ eligible: EligibleWritersAtRef,
2603
+ ): Promise<{ approvers: ChangeRequestPerson[]; unresolvedApprovers: number }> {
2604
+ const addresses = new Set<string>();
2605
+ let everyone = false;
2606
+ for (const info of eligible?.values() ?? []) {
2607
+ for (const email of info.emails) addresses.add(canonicalEmail(email));
2608
+ if (info.roles.includes(EVERYONE_CANONICAL)) everyone = true;
2609
+ }
2610
+ let accounts = new Map<string, { id: string; email: string; name: string }>();
2611
+ if (addresses.size > 0 && this.accounts) {
2612
+ try {
2613
+ accounts = await this.accounts.findActiveAccountsByEmail([...addresses]);
2614
+ } catch (err) {
2615
+ crLog.warn('the approvers of a change request could not be read as accounts; the listeners are handed none:', { err });
2616
+ }
2617
+ }
2618
+ const approvers: ChangeRequestPerson[] = [];
2619
+ let unresolvedApprovers = everyone ? 1 : 0;
2620
+ for (const address of addresses) {
2621
+ const account = accounts.get(address);
2622
+ if (account) approvers.push({ id: account.id, email: account.email, name: account.name });
2623
+ else unresolvedApprovers++;
2624
+ }
2625
+ approvers.sort((a, b) => a.email.localeCompare(b.email));
2626
+ return { approvers, unresolvedApprovers };
2472
2627
  }
2473
2628
 
2474
2629
  /**
@@ -4694,37 +4849,30 @@ export class WorkflowService implements IWorkflowService {
4694
4849
  }
4695
4850
 
4696
4851
  /**
4697
- * Build the `## Affected owners` markdown block that gets appended to
4698
- * the CR body. Lists the eligible approvers (roles + named users) per
4699
- * changed path, per the access tree on `origin/<targetBranch>` — the same
4700
- * tree the approval gate uses server-side, so what the body advertises
4701
- * matches who can actually approve at merge time. The per-path list is
4702
- * capped (see {@link formatAffectedOwnersBlock}) so the body stays within
4703
- * the `gh pr create --body` arg limit on large changesets.
4704
- *
4705
- * Returns the empty string when there are no touched paths or no
4706
- * resolvable eligibility — keeps the body clean rather than printing
4707
- * a stub heading.
4852
+ * The eligible approvers (roles + named users, and the addresses they
4853
+ * expand to) per changed path, per the access tree on
4854
+ * `origin/<targetBranch>` — the same tree the approval gate uses
4855
+ * server-side, so what the body advertises and the listeners are told
4856
+ * matches who can actually approve at merge time. Null when there are no
4857
+ * touched paths or no resolvable eligibility: a missing affected-owners
4858
+ * block is cosmetic, not a reason to abort the open.
4708
4859
  */
4709
- private async buildAffectedOwnersBlock(
4860
+ private async eligibleWritersFor(
4710
4861
  workspaceId: string,
4711
4862
  targetBranch: string,
4712
4863
  paths: string[],
4713
- ): Promise<string> {
4714
- if (paths.length === 0) return '';
4715
- let resolved: Awaited<ReturnType<IAccessControl['eligibleWritersForPathsAtRef']>> = null;
4864
+ ): Promise<EligibleWritersAtRef> {
4865
+ if (paths.length === 0) return null;
4716
4866
  try {
4717
- resolved = await this.accessControl.eligibleWritersForPathsAtRef(
4867
+ return await this.accessControl.eligibleWritersForPathsAtRef(
4718
4868
  workspaceId,
4719
4869
  `origin/${targetBranch}`,
4720
4870
  paths,
4721
4871
  );
4722
4872
  } catch (err) {
4723
4873
  log.warn('eligibleWritersForPathsAtRef failed:', { err });
4874
+ return null;
4724
4875
  }
4725
- if (!resolved || resolved.size === 0) return '';
4726
-
4727
- return formatAffectedOwnersBlock(paths, resolved);
4728
4876
  }
4729
4877
 
4730
4878
  async updateBranch(
@@ -0,0 +1,52 @@
1
+ import type { ILogger } from './logger.contract.js';
2
+
3
+ /**
4
+ * A listener answers how it reached the people an event concerns — a
5
+ * non-empty channel name, `'email'` say — or null when it did not (nothing
6
+ * configured, nobody to tell, the send failed). It is awaited: the caller's
7
+ * answer says who was notified, truthfully, so a listener bounds its own
8
+ * wait. A throw is logged and counts as null; it never fails the caller. An
9
+ * empty name is no channel and counts as null too.
10
+ */
11
+ export type NotifyingListener<E> = (event: E) => Promise<string | null>;
12
+
13
+ /**
14
+ * The one way core runs the notification listeners a deployment registers
15
+ * on a seam (an account invited, a change request opened): every listener
16
+ * in registration order, each awaited, the channels that reached somebody
17
+ * collected in that order, a failure logged and counted as "did not" so the
18
+ * event's own effect — the account made, the request opened — stands
19
+ * whatever the listeners do. A seam wraps one of these per event and keeps
20
+ * its own typed registration method, so the rule above is written once.
21
+ */
22
+ export class ListenerSeam<E> {
23
+ private readonly listeners: NotifyingListener<E>[] = [];
24
+
25
+ /**
26
+ * @param log Where a failed listener is reported.
27
+ * @param failed The line logged for one, said in the seam's own terms
28
+ * ("an account-invited listener failed; the person was not notified by it:").
29
+ */
30
+ constructor(
31
+ private readonly log: ILogger,
32
+ private readonly failed: string,
33
+ ) {}
34
+
35
+ add(listener: NotifyingListener<E>): void {
36
+ this.listeners.push(listener);
37
+ }
38
+
39
+ /** Run every listener in registration order; the channels that reached somebody, in that order. */
40
+ async run(event: E): Promise<string[]> {
41
+ const notified: string[] = [];
42
+ for (const listener of this.listeners) {
43
+ try {
44
+ const channel = await listener(event);
45
+ if (channel !== null && channel !== '') notified.push(channel);
46
+ } catch (err) {
47
+ this.log.error(this.failed, { err });
48
+ }
49
+ }
50
+ return notified;
51
+ }
52
+ }