@bevel-software/platform-core-backend 0.21.0 → 0.22.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 (177) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +32 -2
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +13 -1
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +51 -5
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/modules/access/access-requests.contract.d.ts +75 -0
  9. package/dist/modules/access/access-requests.contract.d.ts.map +1 -0
  10. package/dist/modules/access/access-requests.contract.js +20 -0
  11. package/dist/modules/access/access-requests.contract.js.map +1 -0
  12. package/dist/modules/access/access-requests.routes.d.ts +35 -0
  13. package/dist/modules/access/access-requests.routes.d.ts.map +1 -0
  14. package/dist/modules/access/access-requests.routes.js +237 -0
  15. package/dist/modules/access/access-requests.routes.js.map +1 -0
  16. package/dist/modules/access/access-requests.service.d.ts +123 -0
  17. package/dist/modules/access/access-requests.service.d.ts.map +1 -0
  18. package/dist/modules/access/access-requests.service.js +337 -0
  19. package/dist/modules/access/access-requests.service.js.map +1 -0
  20. package/dist/modules/access-model/access-grammar.d.ts +12 -0
  21. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  22. package/dist/modules/access-model/access-grammar.js +24 -0
  23. package/dist/modules/access-model/access-grammar.js.map +1 -1
  24. package/dist/modules/auth/auth.service.d.ts +26 -1
  25. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  26. package/dist/modules/auth/auth.service.js +11 -2
  27. package/dist/modules/auth/auth.service.js.map +1 -1
  28. package/dist/modules/github-app/github-app.client.d.ts +101 -0
  29. package/dist/modules/github-app/github-app.client.d.ts.map +1 -0
  30. package/dist/modules/github-app/github-app.client.js +222 -0
  31. package/dist/modules/github-app/github-app.client.js.map +1 -0
  32. package/dist/modules/github-app/github-app.connection.d.ts +102 -0
  33. package/dist/modules/github-app/github-app.connection.d.ts.map +1 -0
  34. package/dist/modules/github-app/github-app.connection.js +185 -0
  35. package/dist/modules/github-app/github-app.connection.js.map +1 -0
  36. package/dist/modules/github-app/github-app.routes.d.ts +59 -0
  37. package/dist/modules/github-app/github-app.routes.d.ts.map +1 -0
  38. package/dist/modules/github-app/github-app.routes.js +323 -0
  39. package/dist/modules/github-app/github-app.routes.js.map +1 -0
  40. package/dist/modules/github-app/index.d.ts +4 -0
  41. package/dist/modules/github-app/index.d.ts.map +1 -0
  42. package/dist/modules/github-app/index.js +4 -0
  43. package/dist/modules/github-app/index.js.map +1 -0
  44. package/dist/modules/kb-fs/remote-url.d.ts +22 -0
  45. package/dist/modules/kb-fs/remote-url.d.ts.map +1 -0
  46. package/dist/modules/kb-fs/remote-url.js +35 -0
  47. package/dist/modules/kb-fs/remote-url.js.map +1 -0
  48. package/dist/modules/plugins/join-proposals.d.ts +17 -5
  49. package/dist/modules/plugins/join-proposals.d.ts.map +1 -1
  50. package/dist/modules/plugins/join-proposals.js +76 -22
  51. package/dist/modules/plugins/join-proposals.js.map +1 -1
  52. package/dist/modules/plugins/join-requests.service.d.ts +111 -18
  53. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
  54. package/dist/modules/plugins/join-requests.service.js +173 -29
  55. package/dist/modules/plugins/join-requests.service.js.map +1 -1
  56. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
  57. package/dist/modules/plugins/plugins.routes.js +3 -2
  58. package/dist/modules/plugins/plugins.routes.js.map +1 -1
  59. package/dist/modules/settings/deployment-settings.service.d.ts +22 -1
  60. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  61. package/dist/modules/settings/deployment-settings.service.js +93 -4
  62. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  63. package/dist/modules/settings/managed-repository.d.ts +43 -0
  64. package/dist/modules/settings/managed-repository.d.ts.map +1 -0
  65. package/dist/modules/settings/managed-repository.js +60 -0
  66. package/dist/modules/settings/managed-repository.js.map +1 -0
  67. package/dist/modules/settings/repository-source.d.ts +128 -0
  68. package/dist/modules/settings/repository-source.d.ts.map +1 -0
  69. package/dist/modules/settings/repository-source.js +150 -0
  70. package/dist/modules/settings/repository-source.js.map +1 -0
  71. package/dist/modules/settings/setup.routes.d.ts +27 -4
  72. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  73. package/dist/modules/settings/setup.routes.js +187 -13
  74. package/dist/modules/settings/setup.routes.js.map +1 -1
  75. package/dist/modules/skills/skill-access-requests.routes.d.ts +6 -8
  76. package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -1
  77. package/dist/modules/skills/skill-access-requests.routes.js +24 -63
  78. package/dist/modules/skills/skill-access-requests.routes.js.map +1 -1
  79. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  80. package/dist/modules/tool-helpers/tool-context.js +15 -0
  81. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  82. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  83. package/dist/modules/workflow/agent-tools/workflow.tools.js +38 -2
  84. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  85. package/dist/modules/workflow/git/git.service.d.ts +3 -1
  86. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  87. package/dist/modules/workflow/git/git.service.js +17 -4
  88. package/dist/modules/workflow/git/git.service.js.map +1 -1
  89. package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -1
  90. package/dist/modules/workflow/git/node-git-runner.js +5 -0
  91. package/dist/modules/workflow/git/node-git-runner.js.map +1 -1
  92. package/dist/modules/workflow/git/pull-request.service.d.ts +45 -0
  93. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  94. package/dist/modules/workflow/git/pull-request.service.js +99 -0
  95. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  96. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  97. package/dist/modules/workflow/workflow.routes.js +3 -1
  98. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  99. package/dist/modules/workflow/workflow.service.d.ts +34 -7
  100. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  101. package/dist/modules/workflow/workflow.service.js +148 -48
  102. package/dist/modules/workflow/workflow.service.js.map +1 -1
  103. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +66 -0
  104. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  105. package/dist/modules/workspace/startup/kb-startup-runner.js +148 -0
  106. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  107. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  108. package/dist/modules/workspace/workspace.service.js +17 -1
  109. package/dist/modules/workspace/workspace.service.js.map +1 -1
  110. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  111. package/dist/modules/workspace/workspace.tools.js +28 -2
  112. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  113. package/dist/shared/domain-errors.d.ts +39 -0
  114. package/dist/shared/domain-errors.d.ts.map +1 -1
  115. package/dist/shared/domain-errors.js +55 -0
  116. package/dist/shared/domain-errors.js.map +1 -1
  117. package/dist/shared/git.contract.d.ts +10 -0
  118. package/dist/shared/git.contract.d.ts.map +1 -1
  119. package/dist/shared/git.contract.js.map +1 -1
  120. package/package.json +3 -3
  121. package/src/core/create-core-server.ts +37 -1
  122. package/src/core/create-core-services.ts +65 -8
  123. package/src/modules/access/__tests__/access-requests.recut.test.ts +134 -0
  124. package/src/modules/access/__tests__/access-requests.routes.test.ts +610 -0
  125. package/src/modules/access/access-requests.contract.ts +93 -0
  126. package/src/modules/access/access-requests.routes.ts +286 -0
  127. package/src/modules/access/access-requests.service.ts +420 -0
  128. package/src/modules/access-model/access-grammar.ts +22 -0
  129. package/src/modules/auth/__tests__/auth.service.test.ts +38 -0
  130. package/src/modules/auth/auth.service.ts +28 -1
  131. package/src/modules/github-app/__tests__/github-app.test.ts +848 -0
  132. package/src/modules/github-app/github-app.client.ts +268 -0
  133. package/src/modules/github-app/github-app.connection.ts +204 -0
  134. package/src/modules/github-app/github-app.routes.ts +359 -0
  135. package/src/modules/github-app/index.ts +19 -0
  136. package/src/modules/kb-fs/__tests__/remote-url.test.ts +38 -0
  137. package/src/modules/kb-fs/remote-url.ts +35 -0
  138. package/src/modules/plugins/__tests__/join-proposals.test.ts +100 -19
  139. package/src/modules/plugins/__tests__/join-requests.service.test.ts +27 -11
  140. package/src/modules/plugins/__tests__/join-requests.settlement.test.ts +371 -0
  141. package/src/modules/plugins/__tests__/plugins.routes.test.ts +1 -1
  142. package/src/modules/plugins/join-proposals.ts +87 -19
  143. package/src/modules/plugins/join-requests.service.ts +199 -39
  144. package/src/modules/plugins/plugins.routes.ts +3 -2
  145. package/src/modules/settings/__tests__/repository-source.test.ts +191 -0
  146. package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +353 -0
  147. package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +282 -0
  148. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +233 -0
  149. package/src/modules/settings/deployment-settings.service.ts +109 -3
  150. package/src/modules/settings/managed-repository.ts +68 -0
  151. package/src/modules/settings/repository-source.ts +197 -0
  152. package/src/modules/settings/setup.routes.ts +214 -10
  153. package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +5 -1
  154. package/src/modules/skills/skill-access-requests.routes.ts +25 -75
  155. package/src/modules/tool-helpers/tool-context.ts +15 -0
  156. package/src/modules/workflow/__tests__/apply-failure.test.ts +9 -1
  157. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +159 -8
  158. package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +146 -40
  159. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +141 -1
  160. package/src/modules/workflow/agent-tools/workflow.tools.ts +49 -3
  161. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +121 -0
  162. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +145 -2
  163. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-delete.test.ts +166 -0
  164. package/src/modules/workflow/git/git.service.ts +25 -4
  165. package/src/modules/workflow/git/node-git-runner.ts +6 -0
  166. package/src/modules/workflow/git/pull-request.service.ts +119 -0
  167. package/src/modules/workflow/workflow.routes.ts +3 -1
  168. package/src/modules/workflow/workflow.service.ts +170 -52
  169. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +43 -0
  170. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +233 -7
  171. package/src/modules/workspace/__tests__/workspace.tools.test.ts +35 -3
  172. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +211 -0
  173. package/src/modules/workspace/startup/kb-startup-runner.ts +159 -0
  174. package/src/modules/workspace/workspace.service.ts +17 -1
  175. package/src/modules/workspace/workspace.tools.ts +35 -0
  176. package/src/shared/domain-errors.ts +58 -0
  177. package/src/shared/git.contract.ts +10 -0
@@ -0,0 +1,353 @@
1
+ import type { Server as HttpServer } from 'node:http';
2
+ import type { AddressInfo } from 'node:net';
3
+ import express from 'express';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { testKbContext } from '../../../__tests__/kb-context.js';
6
+ import type { KbContext } from '../../../shared/kb-context.js';
7
+ import { createSetupRoutes } from '../setup.routes.js';
8
+ import { DeploymentSettingsService } from '../deployment-settings.service.js';
9
+ import { ManagedRepository } from '../managed-repository.js';
10
+ import { RepositorySource } from '../repository-source.js';
11
+ import type { Database } from '../../database/connection.js';
12
+ import type { IAdminAccessService } from '../../admin/admin.interface.js';
13
+ import type { ConnectionCheck, RepositoryConnection } from '../connection-check.js';
14
+
15
+ const ENC_KEY = 'kToAi8FXWDpDn3A6yQ/60O39bv05N7XzVOIu/0CJrFc=';
16
+ const KB_ENV = ['KB_REPO_URL', 'GIT_TOKEN', 'GIT_USERNAME', 'GIT_MODE', 'GITHUB_TOKEN', 'DEFAULT_BRANCH', 'PROTECTED_BRANCHES'] as const;
17
+
18
+ let server: HttpServer | null = null;
19
+ let savedEnv: Partial<Record<(typeof KB_ENV)[number], string | undefined>> = {};
20
+
21
+ beforeEach(() => {
22
+ savedEnv = {};
23
+ for (const k of KB_ENV) {
24
+ savedEnv[k] = process.env[k];
25
+ delete process.env[k];
26
+ }
27
+ });
28
+
29
+ afterEach(() => {
30
+ server?.close();
31
+ server = null;
32
+ for (const k of KB_ENV) {
33
+ const original = savedEnv[k];
34
+ if (original === undefined) delete process.env[k];
35
+ else process.env[k] = original;
36
+ }
37
+ });
38
+
39
+ /**
40
+ * The setup router with the choice of repository, over settings that are
41
+ * stored in memory. The managed repository is a stand-in that records being
42
+ * asked for; the startup phase and the remote are stand-ins that count.
43
+ */
44
+ function listen(
45
+ opts: {
46
+ kb?: KbContext;
47
+ ensureFails?: boolean;
48
+ stored?: Record<string, string>;
49
+ /** The addresses the startup phase cannot reach. */
50
+ unreachable?: (url: string) => boolean;
51
+ /** The deployment booted onto a repository it could not reach, and came up gated. */
52
+ bootFailed?: boolean;
53
+ } = {},
54
+ ) {
55
+ const db = {
56
+ select: () => ({ from: () => Promise.resolve([]) }),
57
+ insert: () => ({ values: () => ({ onConflictDoUpdate: () => Promise.resolve() }) }),
58
+ delete: () => ({ where: () => Promise.resolve() }),
59
+ } as unknown as Database;
60
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
61
+ const kb = opts.kb ?? testKbContext({ branchModel: null });
62
+ const managed = new ManagedRepository('/data/backups/managed-repository');
63
+ const source = new RepositorySource({ read: (key) => settings.resolve(key), managed });
64
+ const ensured: string[] = [];
65
+ const probed: RepositoryConnection[] = [];
66
+ let phaseRuns = 0;
67
+ /** The address the startup phase found configured when it ran. */
68
+ const phaseSaw: string[] = [];
69
+ /** Stands until the deployment is pointed somewhere the phase can reach. */
70
+ let bootFailed = opts.bootFailed ?? false;
71
+ const app = express();
72
+ app.use(express.json());
73
+ app.use((req, _res, next) => {
74
+ req.userEmail = 'root@example.com';
75
+ req.userId = 'user-1';
76
+ next();
77
+ });
78
+ app.use(
79
+ '/api',
80
+ createSetupRoutes(
81
+ settings,
82
+ { isAdmin: async () => true } as IAdminAccessService,
83
+ {
84
+ runAll: async () => {
85
+ phaseRuns += 1;
86
+ phaseSaw.push(source.url());
87
+ if (opts.unreachable?.(source.url())) throw new Error(`fatal: repository '${source.url()}' not found`);
88
+ bootFailed = false;
89
+ },
90
+ // A boot that survived an unreachable repository, as the runner reports it.
91
+ ...(opts.bootFailed ? { lastFailure: () => (bootFailed ? 'fatal: unable to access the repository' : null) } : {}),
92
+ },
93
+ kb,
94
+ undefined,
95
+ async (connection): Promise<ConnectionCheck> => {
96
+ probed.push(connection);
97
+ return { outcome: 'connected', branches: ['main'], defaultBranch: 'main', empty: false };
98
+ },
99
+ undefined,
100
+ undefined,
101
+ undefined,
102
+ undefined,
103
+ {
104
+ source,
105
+ ensureManaged: async (branch) => {
106
+ if (opts.ensureFails) throw new Error('EACCES: permission denied, mkdir /data/backups');
107
+ ensured.push(branch);
108
+ },
109
+ },
110
+ ),
111
+ );
112
+ server = app.listen(0);
113
+ const { port } = server.address() as AddressInfo;
114
+ const base = `http://127.0.0.1:${port}`;
115
+ const save = (entries: Record<string, string>) =>
116
+ fetch(`${base}/api/setup/settings`, {
117
+ method: 'POST',
118
+ headers: { 'Content-Type': 'application/json' },
119
+ body: JSON.stringify({ settings: entries }),
120
+ });
121
+ const status = async () => (await fetch(`${base}/api/setup/status`)).json() as Promise<Record<string, unknown>>;
122
+ return { save, status, settings, source, managed, ensured, probed, kb, phase: { runs: () => phaseRuns, saw: phaseSaw } };
123
+ }
124
+
125
+ const HOSTED = { kbRepoUrl: 'https://git.example.com/acme/kb.git', gitToken: 'the-token' };
126
+ const BRANCHES = { defaultBranch: 'main', protectedBranches: 'main' };
127
+
128
+ describe('setup status: the choice of repository', () => {
129
+ it('offers the ways there are, and says none is chosen on a new deployment', async () => {
130
+ const { status } = listen();
131
+ expect(await status()).toMatchObject({ complete: false, repository: { mode: null, modes: ['managed', 'token'] } });
132
+ });
133
+
134
+ it('says a deployment configured before there was a choice is on the way it has', async () => {
135
+ const { status, save } = listen({ kb: testKbContext() });
136
+ await save({ ...HOSTED, ...BRANCHES });
137
+ expect(await status()).toMatchObject({ repository: { mode: 'token' } });
138
+ });
139
+ });
140
+
141
+ describe('choosing a repository the deployment keeps', () => {
142
+ it('finishes setup on that choice alone', async () => {
143
+ const { save, ensured, probed, phase, managed, kb } = listen();
144
+ const res = await save({ gitMode: 'managed' });
145
+ expect(res.status).toBe(200);
146
+ const body = (await res.json()) as Record<string, unknown>;
147
+ expect(body).toMatchObject({ ok: true, complete: true, restartRequired: false, repository: { mode: 'managed' } });
148
+ // The repository was there before the save pointed everything at it,
149
+ expect(ensured).toEqual(['main']);
150
+ // no host was asked anything,
151
+ expect(probed).toEqual([]);
152
+ // and the startup phase ran against it, on the branches named for it.
153
+ expect(phase.runs()).toBe(1);
154
+ expect(phase.saw).toEqual([managed.path]);
155
+ expect(kb.defaultBranch).toBe('main');
156
+ expect([...kb.protectedBranches]).toEqual(['main']);
157
+ });
158
+
159
+ it('keeps the branches an admin named over its own', async () => {
160
+ const { save, ensured, settings } = listen();
161
+ const res = await save({ gitMode: 'managed', defaultBranch: 'live', protectedBranches: 'live, draft' });
162
+ expect(res.status).toBe(200);
163
+ expect(ensured).toEqual(['live']);
164
+ expect(settings.resolve('defaultBranch')).toBe('live');
165
+ expect(settings.resolve('protectedBranches')).toBe('live, draft');
166
+ });
167
+
168
+ it('names no branches for any other way: a repository that exists is asked for its own', async () => {
169
+ const { save, settings } = listen();
170
+ await save({ ...HOSTED, gitMode: 'token' });
171
+ expect(settings.resolve('defaultBranch')).toBe('');
172
+ expect(settings.resolve('protectedBranches')).toBe('');
173
+ });
174
+
175
+ it('refuses the choice when the repository cannot be created, and stores nothing', async () => {
176
+ const { save, settings, phase } = listen({ ensureFails: true });
177
+ const res = await save({ gitMode: 'managed' });
178
+ expect(res.status).toBe(400);
179
+ const body = (await res.json()) as { error: string; problems: Record<string, string> };
180
+ expect(body.problems.gitMode).toMatch(/could not be created/);
181
+ // What the disk said stays in the log.
182
+ expect(JSON.stringify(body)).not.toMatch(/EACCES|\/data\/backups/);
183
+ expect(settings.resolve('gitMode')).toBe('');
184
+ expect(phase.runs()).toBe(0);
185
+ });
186
+
187
+ it('refuses a way it does not know', async () => {
188
+ const { save } = listen();
189
+ const res = await save({ gitMode: 'carrier-pigeon' });
190
+ expect(res.status).toBe(400);
191
+ expect(((await res.json()) as { problems: Record<string, string> }).problems.gitMode).toMatch(/managed, github-app, token/);
192
+ });
193
+ });
194
+
195
+ describe('a deployment that is serving moves to another repository', () => {
196
+ async function serving() {
197
+ const mounted = listen({ kb: testKbContext() });
198
+ await mounted.save({ ...HOSTED, ...BRANCHES });
199
+ return mounted;
200
+ }
201
+
202
+ it('owes a restart, and says both where it is and where it is going', async () => {
203
+ const { save, status, phase, ensured } = await serving();
204
+ const runsBefore = phase.runs();
205
+ const res = await save({ gitMode: 'managed' });
206
+ const body = (await res.json()) as Record<string, unknown>;
207
+ expect(body).toMatchObject({ ok: true, complete: true, restartRequired: true, repository: { mode: 'token', chosen: 'managed' } });
208
+ expect(await status()).toMatchObject({ complete: true, repository: { mode: 'token', chosen: 'managed' } });
209
+ expect(ensured).toHaveLength(1);
210
+ // Sessions may be live: the phase that moves working copies waits for the restart.
211
+ expect(phase.runs()).toBe(runsBefore);
212
+ });
213
+
214
+ /**
215
+ * The move is a fact about the NEXT start. Until then every working copy
216
+ * is a clone of the repository the deployment had, so that is the
217
+ * repository git is handed the address of, and its token is the one
218
+ * presented. Reading the mode live moved the process at the save: pushes
219
+ * went out with no credential, and a branch opened in between was cloned
220
+ * from a repository nothing had prepared.
221
+ */
222
+ it('goes on working against the repository it has until it is started again', async () => {
223
+ const { save, source } = await serving();
224
+ await save({ gitMode: 'managed' });
225
+ expect(source.mode()).toBe('token');
226
+ expect(source.url()).toBe(HOSTED.kbRepoUrl);
227
+ expect(source.credentials.token()).toBe('the-token');
228
+ expect(source.credentials.username()).toBe('x-access-token');
229
+ // A token rotated meanwhile is still the one the next push carries.
230
+ await save({ gitToken: 'a-rotated-token' });
231
+ expect(source.credentials.token()).toBe('a-rotated-token');
232
+ });
233
+
234
+ it('is on the repository it chose once it is started again, and presents it nothing of the old one', async () => {
235
+ const { save, source, managed } = await serving();
236
+ await save({ gitMode: 'managed' });
237
+ // What a restart does: the source is built on the mode chosen.
238
+ source.takeEffect();
239
+ expect(source.mode()).toBe('managed');
240
+ expect(source.url()).toBe(managed.path);
241
+ expect(source.credentials.token()).toBeNull();
242
+ });
243
+
244
+ it('goes on owing the restart through saves about other things', async () => {
245
+ const { save } = await serving();
246
+ await save({ gitMode: 'managed' });
247
+ const body = (await (await save({ kbSyncSecret: 'a-secret-of-sixteen-or-more' })).json()) as Record<string, unknown>;
248
+ expect(body).toMatchObject({ ok: true, restartRequired: true, repository: { mode: 'token', chosen: 'managed' } });
249
+ });
250
+
251
+ it('owes nothing once the move is taken back', async () => {
252
+ const { save, probed } = await serving();
253
+ await save({ gitMode: 'managed' });
254
+ const asked = probed.length;
255
+ const body = (await (await save({ gitMode: 'token' })).json()) as Record<string, unknown>;
256
+ expect(body).toMatchObject({ ok: true, restartRequired: false, repository: { mode: 'token', chosen: 'token' } });
257
+ // Nothing about the connection changed, so the host is not asked again.
258
+ expect(probed).toHaveLength(asked);
259
+ });
260
+
261
+ it('owes none for naming the way it was already on', async () => {
262
+ const { save, probed } = await serving();
263
+ const asked = probed.length;
264
+ const body = (await (await save({ gitMode: 'token' })).json()) as Record<string, unknown>;
265
+ expect(body).toMatchObject({ ok: true, restartRequired: false, repository: { mode: 'token', chosen: 'token' } });
266
+ expect(probed).toHaveLength(asked);
267
+ });
268
+ });
269
+
270
+ /**
271
+ * The pin protects what is running on the mode in effect. Behind a shut
272
+ * gate nothing is, however completely the settings were answered, and a
273
+ * deployment pinned there could not be got out of a repository that does
274
+ * not work by choosing one that does.
275
+ */
276
+ describe('a deployment that answered everything and serves nobody', () => {
277
+ const isHosted = (url: string) => url === HOSTED.kbRepoUrl;
278
+
279
+ it('is got going by choosing another way, when its first initialisation failed', async () => {
280
+ const { save, status, phase, managed, source } = listen({ kb: testKbContext(), unreachable: isHosted });
281
+ // First run: an address and a token, accepted, and a first clone that fails.
282
+ const first = await save({ ...HOSTED, ...BRANCHES });
283
+ expect(first.status).toBe(500);
284
+ expect(await status()).toMatchObject({ complete: false, kbInit: expect.any(Object), repository: { mode: 'token' } });
285
+
286
+ const second = await save({ gitMode: 'managed' });
287
+ const body = (await second.json()) as Record<string, unknown>;
288
+ expect(second.status, JSON.stringify(body)).toBe(200);
289
+ // The retry ran against the repository that was chosen, not the one that had just failed,
290
+ expect(phase.saw).toEqual([HOSTED.kbRepoUrl, managed.path]);
291
+ // nothing is owed, and the gate is open.
292
+ expect(body).toMatchObject({ complete: true, restartRequired: false, repository: { mode: 'managed', chosen: 'managed' } });
293
+ expect(source.credentials.token()).toBeNull();
294
+ expect(await status()).toMatchObject({ complete: true });
295
+ expect(await status()).not.toHaveProperty('kbInit');
296
+ });
297
+
298
+ it('is got going the same way when it booted onto a repository it could not reach', async () => {
299
+ Object.assign(process.env, { KB_REPO_URL: HOSTED.kbRepoUrl, GIT_TOKEN: HOSTED.gitToken, DEFAULT_BRANCH: 'main', PROTECTED_BRANCHES: 'main' });
300
+ const { save, status, phase, managed } = listen({ kb: testKbContext(), bootFailed: true, unreachable: isHosted });
301
+ expect(await status()).toMatchObject({ complete: false, repository: { mode: 'token' } });
302
+
303
+ const body = (await (await save({ gitMode: 'managed' })).json()) as Record<string, unknown>;
304
+ expect(phase.saw).toEqual([managed.path]);
305
+ expect(body).toMatchObject({ complete: true, restartRequired: false, repository: { mode: 'managed', chosen: 'managed' } });
306
+ });
307
+
308
+ it('is still asked for a restart once it IS serving: the same save, a different deployment', async () => {
309
+ const { save, phase } = listen({ kb: testKbContext() });
310
+ await save({ ...HOSTED, ...BRANCHES });
311
+ const runs = phase.runs();
312
+ const body = (await (await save({ gitMode: 'managed' })).json()) as Record<string, unknown>;
313
+ expect(body).toMatchObject({ complete: true, restartRequired: true, repository: { mode: 'token', chosen: 'managed' } });
314
+ expect(phase.runs()).toBe(runs);
315
+ });
316
+ });
317
+
318
+ describe('a deployment whose way is chosen by the environment', () => {
319
+ it('says which variable chose', async () => {
320
+ process.env.GIT_MODE = 'managed';
321
+ const { status } = listen();
322
+ expect(await status()).toMatchObject({ repository: { mode: 'managed', chosen: 'managed', pinned: 'GIT_MODE' } });
323
+ });
324
+
325
+ it('says nothing of the kind when the admin chose', async () => {
326
+ const { save, status } = listen();
327
+ await save({ gitMode: 'managed' });
328
+ expect(((await status()) as { repository: object }).repository).not.toHaveProperty('pinned');
329
+ });
330
+ });
331
+
332
+ describe('a mount without the choice', () => {
333
+ it('is the one way there was: an address and a token', async () => {
334
+ const db = {
335
+ select: () => ({ from: () => Promise.resolve([]) }),
336
+ insert: () => ({ values: () => ({ onConflictDoUpdate: () => Promise.resolve() }) }),
337
+ delete: () => ({ where: () => Promise.resolve() }),
338
+ } as unknown as Database;
339
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
340
+ const app = express();
341
+ app.use(express.json());
342
+ app.use((req, _res, next) => {
343
+ req.userEmail = 'root@example.com';
344
+ next();
345
+ });
346
+ app.use('/api', createSetupRoutes(settings, { isAdmin: async () => true } as IAdminAccessService, { runAll: async () => {} }, testKbContext()));
347
+ server = app.listen(0);
348
+ const { port } = server.address() as AddressInfo;
349
+ const status = (await (await fetch(`http://127.0.0.1:${port}/api/setup/status`)).json()) as Record<string, unknown>;
350
+ expect(status).not.toHaveProperty('repository');
351
+ expect(status.complete).toBe(false);
352
+ });
353
+ });
@@ -0,0 +1,282 @@
1
+ import type { Server as HttpServer } from 'node:http';
2
+ import type { AddressInfo } from 'node:net';
3
+ import express from 'express';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { testKbContext } from '../../../__tests__/kb-context.js';
6
+ import type { KbContext } from '../../../shared/kb-context.js';
7
+ import { createSetupRoutes } from '../setup.routes.js';
8
+ import { DeploymentSettingsService } from '../deployment-settings.service.js';
9
+ import { ManagedRepository } from '../managed-repository.js';
10
+ import { RepositorySource, type GitHubAppRepository } from '../repository-source.js';
11
+ import type { Database } from '../../database/connection.js';
12
+ import type { IAdminAccessService } from '../../admin/admin.interface.js';
13
+ import type { ConnectionCheck, RepositoryConnection } from '../connection-check.js';
14
+
15
+ const ENC_KEY = 'kToAi8FXWDpDn3A6yQ/60O39bv05N7XzVOIu/0CJrFc=';
16
+ const KB_ENV = ['KB_REPO_URL', 'GIT_TOKEN', 'GIT_USERNAME', 'GIT_MODE', 'GITHUB_TOKEN', 'DEFAULT_BRANCH', 'PROTECTED_BRANCHES', 'GITHUB_APP_REPOSITORY'] as const;
17
+
18
+ let server: HttpServer | null = null;
19
+ let savedEnv: Partial<Record<(typeof KB_ENV)[number], string | undefined>> = {};
20
+
21
+ beforeEach(() => {
22
+ savedEnv = {};
23
+ for (const k of KB_ENV) {
24
+ savedEnv[k] = process.env[k];
25
+ delete process.env[k];
26
+ }
27
+ });
28
+
29
+ afterEach(() => {
30
+ server?.close();
31
+ server = null;
32
+ for (const k of KB_ENV) {
33
+ const original = savedEnv[k];
34
+ if (original === undefined) delete process.env[k];
35
+ else process.env[k] = original;
36
+ }
37
+ });
38
+
39
+ type Answer = ConnectionCheck | ((connection: RepositoryConnection) => ConnectionCheck);
40
+ const CONNECTED: ConnectionCheck = { outcome: 'connected', branches: ['trunk', 'draft'], defaultBranch: 'trunk', empty: false };
41
+
42
+ /**
43
+ * The setup router over a deployment that has a GitHub App installed (or
44
+ * not), with GitHub's answer to the connection check given by the suite.
45
+ */
46
+ function listen(
47
+ opts: {
48
+ kb?: KbContext;
49
+ installed?: boolean;
50
+ tokenGiven?: boolean;
51
+ offered?: boolean;
52
+ github?: Answer;
53
+ /** What the person who connected GitHub could push to. */
54
+ permitted?: string[];
55
+ } = {},
56
+ ) {
57
+ const db = {
58
+ select: () => ({ from: () => Promise.resolve([]) }),
59
+ insert: () => ({ values: () => ({ onConflictDoUpdate: () => Promise.resolve() }) }),
60
+ delete: () => ({ where: () => Promise.resolve() }),
61
+ } as unknown as Database;
62
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
63
+ const kb = opts.kb ?? testKbContext({ branchModel: null });
64
+ let prepared = 0;
65
+ const githubApp: GitHubAppRepository = {
66
+ url: (read) => (read('githubRepository') ? `https://github.com/${read('githubRepository')}.git` : ''),
67
+ answered: (read) => (opts.installed ?? true) && Boolean(read('githubRepository')),
68
+ prepare: async () => {
69
+ prepared += 1;
70
+ if (opts.tokenGiven === false) throw new Error('GitHub answered 404');
71
+ },
72
+ token: () => (opts.tokenGiven === false || prepared === 0 ? null : 'an-installation-token'),
73
+ permits: (repository) => (opts.permitted ?? ['acme/kb', 'acme/another']).includes(repository),
74
+ };
75
+ const source = new RepositorySource({
76
+ read: (key) => settings.resolve(key),
77
+ managed: new ManagedRepository('/data/backups/managed-repository'),
78
+ githubApp,
79
+ });
80
+ const probed: RepositoryConnection[] = [];
81
+ let phaseRuns = 0;
82
+ const app = express();
83
+ app.use(express.json());
84
+ app.use((req, _res, next) => {
85
+ req.userEmail = 'root@example.com';
86
+ req.userId = 'user-1';
87
+ next();
88
+ });
89
+ app.use(
90
+ '/api',
91
+ createSetupRoutes(
92
+ settings,
93
+ { isAdmin: async () => true } as IAdminAccessService,
94
+ { runAll: async () => void (phaseRuns += 1) },
95
+ kb,
96
+ undefined,
97
+ async (connection) => {
98
+ probed.push(connection);
99
+ const answer = opts.github ?? CONNECTED;
100
+ return typeof answer === 'function' ? answer(connection) : answer;
101
+ },
102
+ undefined,
103
+ undefined,
104
+ undefined,
105
+ undefined,
106
+ { source, ensureManaged: async () => undefined, ...(opts.offered === false ? {} : { githubApp }) },
107
+ ),
108
+ );
109
+ server = app.listen(0);
110
+ const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
111
+ const save = async (entries: Record<string, string>) => {
112
+ const res = await fetch(`${base}/api/setup/settings`, {
113
+ method: 'POST',
114
+ headers: { 'Content-Type': 'application/json' },
115
+ body: JSON.stringify({ settings: entries }),
116
+ });
117
+ return { status: res.status, body: (await res.json()) as Record<string, unknown> & { problems?: Record<string, string> } };
118
+ };
119
+ const status = async () => (await fetch(`${base}/api/setup/status`)).json() as Promise<Record<string, unknown>>;
120
+ return { save, status, settings, source, probed, kb, phaseRuns: () => phaseRuns };
121
+ }
122
+
123
+ const CHOICE = { gitMode: 'github-app', githubRepository: 'acme/kb' };
124
+
125
+ describe('setup status: GitHub as a way of having a repository', () => {
126
+ it('is offered second, by a deployment that can connect to it', async () => {
127
+ expect(await listen().status()).toMatchObject({ repository: { mode: null, modes: ['managed', 'github-app', 'token'] } });
128
+ });
129
+
130
+ it('is not offered by one that cannot', async () => {
131
+ expect(await listen({ offered: false }).status()).toMatchObject({ repository: { modes: ['managed', 'token'] } });
132
+ });
133
+ });
134
+
135
+ describe('choosing a repository on GitHub', () => {
136
+ it('finishes setup once GitHub accepts the installation for reading and writing', async () => {
137
+ const { save, probed, settings, kb, phaseRuns, source } = listen();
138
+ const { status, body } = await save(CHOICE);
139
+ expect(status, JSON.stringify(body)).toBe(200);
140
+ expect(body).toMatchObject({ ok: true, complete: true, restartRequired: false, repository: { mode: 'github-app' } });
141
+ // Asked with what git will present: the installation's token, never a stored one.
142
+ expect(probed).toEqual([{ url: 'https://github.com/acme/kb.git', token: 'an-installation-token', username: 'x-access-token' }]);
143
+ expect(source.url()).toBe('https://github.com/acme/kb.git');
144
+ expect(settings.resolve('githubRepository')).toBe('acme/kb');
145
+ expect(phaseRuns()).toBe(1);
146
+ // The repository said what it calls its trunk.
147
+ expect(kb.defaultBranch).toBe('trunk');
148
+ expect([...kb.protectedBranches]).toEqual(['trunk']);
149
+ });
150
+
151
+ it('names the usual branch for a repository that is empty', async () => {
152
+ const { save, kb } = listen({ github: { outcome: 'connected', branches: [], defaultBranch: null, empty: true } });
153
+ expect((await save(CHOICE)).status).toBe(200);
154
+ expect(kb.defaultBranch).toBe('main');
155
+ });
156
+
157
+ it('keeps the branches an admin named over the repository trunk', async () => {
158
+ const { save, settings } = listen();
159
+ await save({ ...CHOICE, defaultBranch: 'live', protectedBranches: 'live, draft' });
160
+ expect(settings.resolve('defaultBranch')).toBe('live');
161
+ expect(settings.resolve('protectedBranches')).toBe('live, draft');
162
+ });
163
+
164
+ /**
165
+ * The name is the one part of this connection an admin types, and the
166
+ * installation's token would answer for any repository the installation
167
+ * covers. So the name is held to what the person who connected GitHub
168
+ * could push to with their own account, BEFORE that token is used for
169
+ * anything: GitHub is not asked, so nothing about the repository, not
170
+ * even that it exists, comes back.
171
+ */
172
+ it('refuses a repository the person who connected GitHub could not push to, without asking GitHub', async () => {
173
+ const { save, settings, probed, phaseRuns } = listen({ permitted: ['x/docs'] });
174
+ for (const name of ['x/payroll', 'x/handbook', 'someone-else/secrets']) {
175
+ const { status, body } = await save({ gitMode: 'github-app', githubRepository: name });
176
+ expect(status, name).toBe(400);
177
+ expect(body.problems?.githubRepository, name).toMatch(/your own GitHub account can write to/);
178
+ }
179
+ expect(probed).toEqual([]);
180
+ expect(settings.resolve('githubRepository')).toBe('');
181
+ expect(settings.resolve('gitMode')).toBe('');
182
+ expect(phaseRuns()).toBe(0);
183
+ // The one they can push to is theirs to connect.
184
+ expect((await save({ gitMode: 'github-app', githubRepository: 'x/docs' })).status).toBe(200);
185
+ });
186
+
187
+ it('refuses a repository the installation no longer reaches, and stores nothing', async () => {
188
+ const { save, settings, phaseRuns } = listen({
189
+ github: { outcome: 'rejected', reason: 'not-found', field: 'kbRepoUrl', error: 'repository not found' },
190
+ });
191
+ const { status, body } = await save({ gitMode: 'github-app', githubRepository: 'acme/kb' });
192
+ expect(status).toBe(400);
193
+ expect(body.problems).toEqual({
194
+ githubRepository: 'The GitHub App cannot reach that repository. Add the repository to the app’s installation on GitHub.',
195
+ });
196
+ expect(settings.resolve('githubRepository')).toBe('');
197
+ expect(settings.resolve('gitMode')).toBe('');
198
+ expect(phaseRuns()).toBe(0);
199
+ });
200
+
201
+ it.each([
202
+ [
203
+ 'it may read and not write',
204
+ { outcome: 'read-only', field: 'gitToken', error: 'x', branches: ['main'], defaultBranch: 'main', empty: false } as ConnectionCheck,
205
+ /can read that repository but not write/,
206
+ ],
207
+ [
208
+ 'GitHub cannot be reached',
209
+ { outcome: 'rejected', reason: 'unreachable', field: 'kbRepoUrl', error: 'x' } as ConnectionCheck,
210
+ /could not be reached/,
211
+ ],
212
+ ])('says so when %s', async (_why, github, said) => {
213
+ const { save } = listen({ github });
214
+ const { status, body } = await save(CHOICE);
215
+ expect(status).toBe(400);
216
+ expect(body.problems?.githubRepository).toMatch(said);
217
+ });
218
+
219
+ it('asks for the connection before a repository can be chosen', async () => {
220
+ const { save, probed } = listen({ installed: false });
221
+ const { status, body } = await save(CHOICE);
222
+ expect(status).toBe(400);
223
+ expect(body.problems).toEqual({ githubRepository: 'Connect GitHub before choosing a repository.' });
224
+ expect(probed).toEqual([]);
225
+ });
226
+
227
+ it('asks for the repository when only the way was chosen', async () => {
228
+ const { save } = listen();
229
+ const { status, body } = await save({ gitMode: 'github-app' });
230
+ expect(status).toBe(400);
231
+ expect(body.problems).toEqual({ githubRepository: 'Choose the repository the knowledge base lives in.' });
232
+ });
233
+
234
+ it('says the app may have been uninstalled when GitHub gives no token', async () => {
235
+ const { save, probed } = listen({ tokenGiven: false });
236
+ const { status, body } = await save(CHOICE);
237
+ expect(status).toBe(400);
238
+ expect(body.problems?.gitMode).toMatch(/may have been uninstalled/);
239
+ expect(probed).toEqual([]);
240
+ });
241
+
242
+ it('refuses the way on a deployment that does not offer it', async () => {
243
+ const { save } = listen({ offered: false });
244
+ const { status, body } = await save(CHOICE);
245
+ expect(status).toBe(400);
246
+ expect(body.problems?.gitMode).toMatch(/cannot connect to GitHub/);
247
+ });
248
+
249
+ it('refuses a name that is not a repository', async () => {
250
+ const { save, probed } = listen();
251
+ for (const name of ['acme', 'acme/kb.git', '../acme/kb', 'https://github.com/acme/kb', 'acme/kb/extra']) {
252
+ const { status, body } = await save({ gitMode: 'github-app', githubRepository: name });
253
+ expect(status, name).toBe(400);
254
+ expect(body.problems?.githubRepository, name).toBe('Name the repository as owner/name.');
255
+ }
256
+ expect(probed).toEqual([]);
257
+ });
258
+ });
259
+
260
+ describe('a deployment on GitHub that is serving', () => {
261
+ async function serving() {
262
+ const mounted = listen({ kb: testKbContext() });
263
+ await mounted.save({ ...CHOICE, defaultBranch: 'main', protectedBranches: 'main' });
264
+ return mounted;
265
+ }
266
+
267
+ it('is not asked about its repository by a save that changes something else', async () => {
268
+ const { save, probed } = await serving();
269
+ const asked = probed.length;
270
+ expect((await save({ kbSyncSecret: 'a-secret-of-sixteen-or-more' })).status).toBe(200);
271
+ expect(probed).toHaveLength(asked);
272
+ });
273
+
274
+ it('is asked again when it moves to another repository', async () => {
275
+ const { save, probed } = await serving();
276
+ const asked = probed.length;
277
+ const { status } = await save({ githubRepository: 'acme/another' });
278
+ expect(status).toBe(200);
279
+ expect(probed).toHaveLength(asked + 1);
280
+ expect(probed.at(-1)!.url).toBe('https://github.com/acme/another.git');
281
+ });
282
+ });