@serve.zone/gitops 3.1.1 → 32.1.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 (38) hide show
  1. package/.smartconfig.json +26 -7
  2. package/changelog.md +42 -0
  3. package/deno.json +1 -1
  4. package/dist_serve/bundle.js +327 -299
  5. package/dist_serve/bundle.js.map +1 -0
  6. package/dist_ts/00_commitinfo_data.js +2 -2
  7. package/dist_ts/cache/classes.cachedb.d.ts +1 -1
  8. package/dist_ts/cache/classes.cachedb.js +1 -1
  9. package/dist_ts/classes/connectionmanager.d.ts +41 -0
  10. package/dist_ts/classes/connectionmanager.js +143 -9
  11. package/dist_ts/opsserver/handlers/webhook.handler.d.ts +20 -0
  12. package/dist_ts/opsserver/handlers/webhook.handler.js +132 -14
  13. package/dist_ts/opsserver/helpers/webhookverification.d.ts +70 -0
  14. package/dist_ts/opsserver/helpers/webhookverification.js +158 -0
  15. package/dist_ts/plugins.d.ts +2 -1
  16. package/dist_ts/plugins.js +3 -2
  17. package/dist_ts/providers/classes.giteaprovider.d.ts +10 -0
  18. package/dist_ts/providers/classes.giteaprovider.js +11 -1
  19. package/dist_ts/providers/classes.gitlabprovider.d.ts +10 -0
  20. package/dist_ts/providers/classes.gitlabprovider.js +11 -1
  21. package/dist_ts_interfaces/data/connection.d.ts +27 -0
  22. package/dist_ts_interfaces/requests/webhook.d.ts +31 -0
  23. package/package.json +9 -9
  24. package/readme.md +60 -3
  25. package/ts/00_commitinfo_data.ts +1 -1
  26. package/ts/cache/classes.cachedb.ts +1 -1
  27. package/ts/classes/connectionmanager.ts +150 -8
  28. package/ts/opsserver/handlers/webhook.handler.ts +175 -14
  29. package/ts/opsserver/helpers/webhookverification.ts +202 -0
  30. package/ts/plugins.ts +2 -1
  31. package/ts/providers/classes.giteaprovider.ts +11 -0
  32. package/ts/providers/classes.gitlabprovider.ts +11 -0
  33. package/ts_interfaces/data/connection.ts +29 -0
  34. package/ts_interfaces/requests/webhook.ts +42 -0
  35. package/ts_web/00_commitinfo_data.ts +1 -1
  36. package/ts_web/appstate.ts +46 -0
  37. package/ts_web/elements/views/connections/index.ts +89 -0
  38. package/readme.todo.md +0 -3
@@ -1,4 +1,5 @@
1
1
  import * as plugins from '../plugins.js';
2
+ import * as data from '../data/index.js';
2
3
  export interface IReq_WebhookNotification extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_WebhookNotification> {
3
4
  method: 'webhookNotification';
4
5
  request: {
@@ -11,3 +12,33 @@ export interface IReq_WebhookNotification extends plugins.typedrequestInterfaces
11
12
  ok: boolean;
12
13
  };
13
14
  }
15
+ export interface IReq_GetWebhookSettings extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_GetWebhookSettings> {
16
+ method: 'getWebhookSettings';
17
+ request: {
18
+ identity: data.IIdentity;
19
+ connectionId: string;
20
+ };
21
+ response: {
22
+ webhookSettings: data.IWebhookSettings;
23
+ };
24
+ }
25
+ export interface IReq_RotateWebhookSecret extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_RotateWebhookSecret> {
26
+ method: 'rotateWebhookSecret';
27
+ request: {
28
+ identity: data.IIdentity;
29
+ connectionId: string;
30
+ };
31
+ response: {
32
+ webhookSettings: data.IWebhookSettings;
33
+ };
34
+ }
35
+ export interface IReq_ConfirmWebhookSecret extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_ConfirmWebhookSecret> {
36
+ method: 'confirmWebhookSecret';
37
+ request: {
38
+ identity: data.IIdentity;
39
+ connectionId: string;
40
+ };
41
+ response: {
42
+ status: data.TWebhookStatus;
43
+ };
44
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serve.zone/gitops",
3
- "version": "3.1.1",
3
+ "version": "32.1.0",
4
4
  "description": "GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs",
5
5
  "main": "./dist_ts/index.js",
6
6
  "typings": "./dist_ts/index.d.ts",
@@ -34,18 +34,18 @@
34
34
  "@push.rocks/smartdata": "7.1.7",
35
35
  "@push.rocks/smartguard": "^3.1.0",
36
36
  "@push.rocks/smartjwt": "^2.2.2",
37
- "@push.rocks/smartmongo": "^7.0.0",
37
+ "@push.rocks/smartmongo": "9.0.0",
38
38
  "@push.rocks/smartsecret": "^1.0.2"
39
39
  },
40
40
  "devDependencies": {
41
- "@git.zone/tsbuild": "^4.4.2",
42
- "@git.zone/tsbundle": "^2.10.4",
43
- "@git.zone/tsdeno": "^1.5.0",
44
- "@git.zone/tsdocker": "2.4.3",
45
- "@git.zone/tsrun": "^2.0.4",
46
- "@git.zone/tstest": "^3.6.6",
41
+ "@git.zone/tsbuild": "^4.5.0",
42
+ "@git.zone/tsbundle": "^2.13.0",
43
+ "@git.zone/tsdeno": "^1.8.0",
44
+ "@git.zone/tsdocker": "^3.6.0",
45
+ "@git.zone/tsrun": "^2.0.6",
46
+ "@git.zone/tstest": "6.1.1",
47
47
  "@git.zone/tswatch": "^3.3.5",
48
- "@types/node": "^25.9.1"
48
+ "@types/node": "^26.5.1"
49
49
  },
50
50
  "files": [
51
51
  "ts/**/*",
package/readme.md CHANGED
@@ -15,7 +15,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
15
15
  - Mirrors repositories from a source connection to a target connection with group offset support, stable source-project mapping for subgroup/path shifts, metadata sync, optional release/asset and OCI image sync, stale target handling, and live sync logs.
16
16
  - Manages reusable secret definitions that can be pushed to selected project or group targets.
17
17
  - Runs scheduled jobs, currently focused on syncing `readme.md`, `changelog.md`, and `docs/*.md` content into BookStack shelves, books, and pages.
18
- - Receives provider webhooks at `POST /webhook/:connectionId` and broadcasts events to connected browser clients.
18
+ - Receives provider webhooks at `POST /webhook/:connectionId`, verifies every delivery against the connection's webhook secret, and broadcasts accepted events to connected browser clients.
19
19
 
20
20
  ## Runtime Shape
21
21
 
@@ -78,7 +78,7 @@ import type * as gitopsInterfaces from '@serve.zone/gitops/interfaces';
78
78
  | `GITOPS_ADMIN_USERNAME` | `admin` | Dashboard login username. |
79
79
  | `GITOPS_ADMIN_PASSWORD` | `admin` | Dashboard login password. |
80
80
 
81
- Provider connections, sync configs, jobs, managed secrets, and action logs are persisted as JSON records below `~/.serve.zone/gitops/storage/`. Cached projects and secrets live in the embedded SmartMongo directory below `~/.serve.zone/gitops/tsmdb/`.
81
+ Provider connections, sync configs, jobs, managed secrets, and action logs are persisted as JSON records below `~/.serve.zone/gitops/storage/`. Cached projects and secrets use a disposable NoSQLDB memory server through SmartMongo 9. The initial provider scan repopulates this cache after startup; it does not persist between processes.
82
82
 
83
83
  Provider connections may also define `registryUrl`, `registryUsername`, and `registryToken` for OCI registry mirroring. Registry tokens are stored through SmartSecret and masked in API/UI responses. GitLab image sync falls back to `oauth2:<provider token>` when no dedicated registry credentials are configured; Gitea requires an explicit registry username when registry auth is needed.
84
84
 
@@ -96,7 +96,64 @@ The backend exposes TypedRequest methods through `/typedrequest` and a custom we
96
96
  | Pipelines and Logs | Pipeline listing, jobs, retry/cancel, and raw job log fetches |
97
97
  | Sync | Sync config CRUD, preview, trigger, status, logs, optional `syncReleases` and `syncContainerImages` artifact mirroring |
98
98
  | Jobs | Scheduled job CRUD, trigger, pause/resume, and logs |
99
- | Webhooks | `POST /webhook/:connectionId` for provider event fan-out |
99
+ | Webhooks | `POST /webhook/:connectionId` for verified provider event fan-out, plus `getWebhookSettings`, `rotateWebhookSecret`, `confirmWebhookSecret` |
100
+
101
+ ## Webhooks
102
+
103
+ Every connection carries its own webhook secret. It is minted with the connection (32 random
104
+ bytes, hex) and kept in the same secret store as the provider token — never in the connection
105
+ JSON, never in an API response other than the reveal below.
106
+
107
+ GitOps does not create webhooks through the provider API, so the secret is transferred by hand:
108
+
109
+ 1. Open **Connections → Webhook** for the connection. The dialog shows the payload URL
110
+ (`https://<gitops host>/webhook/<connectionId>`), the secret, and the header the provider
111
+ will use.
112
+ 2. In Gitea, create the webhook with that payload URL, content type `application/json`, and the
113
+ secret in the **Secret** field. Gitea signs each delivery with HMAC-SHA256 over the raw body
114
+ and sends the hex digest in `X-Gitea-Signature`.
115
+ 3. In GitLab, create the webhook with that URL and put the secret into **Secret token**. GitLab
116
+ echoes it verbatim in `X-Gitlab-Token`.
117
+ 4. Back in GitOps choose **Mark Configured**. Until then the connection's webhook status is
118
+ `unverified` and its deliveries are refused.
119
+
120
+ **Rotate Secret** mints a new secret and sets the status back to `unverified`, so the previous
121
+ secret stops being accepted immediately.
122
+
123
+ Connections created before webhook verification existed are loaded with a freshly minted secret
124
+ and the status `unverified`: their provider side still holds no secret, so nothing they deliver
125
+ can be trusted until step 2/3/4 has been done for them.
126
+
127
+ A refused delivery never triggers a broadcast and is answered with the refusal name:
128
+
129
+ | Refusal | Status | Meaning |
130
+ | --- | --- | --- |
131
+ | `webhook-connection-unknown` | 404 | no connection with that id |
132
+ | `webhook-connection-unverified` | 401 | the secret has not been confirmed at the provider |
133
+ | `webhook-secret-missing` | 401 | the connection has no webhook secret in the secret store |
134
+ | `webhook-secret-unreadable` | 503 | the secret store holds the secret but did not return it |
135
+ | `webhook-length-missing` | 411 | no parsable `Content-Length` on the delivery |
136
+ | `webhook-signature-missing` | 401 | no `X-Gitea-Signature` / `X-Gitlab-Token` header |
137
+ | `webhook-signature-invalid` | 401 | signature or token does not match the connection secret |
138
+ | `webhook-body-too-large` | 413 | delivery body exceeds 1 MiB |
139
+ | `webhook-body-unreadable` | 400 | the request body could not be read |
140
+
141
+ The signature is computed over the bytes as received; GitOps never re-serializes the payload
142
+ before hashing it.
143
+
144
+ Delivery bodies are bounded twice. Gitea and GitLab always declare a `Content-Length`, so a
145
+ delivery without a parsable one is refused as `webhook-length-missing` before its body is
146
+ touched — reading it would mean buffering a stream of unknown size from an unauthenticated
147
+ caller. The declared length itself is only a claim, so the body is then read against a running
148
+ 1 MiB budget and a delivery that sends more than it declared is refused the moment the budget
149
+ is crossed, with nothing beyond the bound buffered.
150
+
151
+ `webhook-secret-unreadable` is the one refusal that is not about the caller: the connection's
152
+ secret is in the secret store but a read of it failed (for example the SmartSecret keyring
153
+ quota). GitOps then refuses the connection's deliveries and leaves the stored secret exactly
154
+ as it is — it never mints a replacement over a secret an operator has already configured at
155
+ the provider. Deliveries resume on their own once the secret store answers again; **Rotate
156
+ Secret** replaces the stored secret deliberately if it really is gone.
100
157
 
101
158
  ## Auto BookStack Documentation
102
159
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/gitops',
6
- version: '3.1.1',
6
+ version: '32.1.0',
7
7
  description: 'GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs'
8
8
  }
@@ -8,7 +8,7 @@ export interface ICacheDbOptions {
8
8
  }
9
9
 
10
10
  /**
11
- * Singleton wrapper around LocalTsmDb + SmartdataDb.
11
+ * Singleton wrapper around SmartMongo's NoSQLDB memory server and SmartData.
12
12
  * Provides a managed MongoDB-compatible cache database.
13
13
  */
14
14
  export class CacheDb {
@@ -9,6 +9,13 @@ const CONNECTIONS_PREFIX = '/connections/';
9
9
  const KEYCHAIN_PREFIX = 'keychain:';
10
10
 
11
11
  const getRegistryTokenSecretId = (connectionId: string): string => `${connectionId}:registry`;
12
+ const getWebhookSecretId = (connectionId: string): string => `${connectionId}:webhook`;
13
+
14
+ /**
15
+ * Mints a webhook secret. 32 random bytes as hex: long enough that a delivery carrying
16
+ * it proves the sender was configured by us, short enough to paste into a provider UI.
17
+ */
18
+ const mintWebhookSecret = (): string => plugins.crypto.randomBytes(32).toString('hex');
12
19
 
13
20
  /**
14
21
  * Manages provider connections — persists each connection as an
@@ -112,7 +119,26 @@ export class ConnectionManager {
112
119
  } else if (conn.registryToken && conn.registryToken !== '***') {
113
120
  await this.migrateRegistryTokenToKeychain(conn);
114
121
  }
122
+ if (conn.webhookSecret?.startsWith(KEYCHAIN_PREFIX)) {
123
+ const webhookSecretId = conn.webhookSecret.substring(KEYCHAIN_PREFIX.length);
124
+ const realWebhookSecret = await this.smartSecret.getSecret(webhookSecretId);
125
+ if (realWebhookSecret) {
126
+ conn.webhookSecret = realWebhookSecret;
127
+ } else {
128
+ // The sentinel proves a secret was stored under this id, so an empty read is a
129
+ // keychain failure, not a missing secret. Minting a replacement here would
130
+ // overwrite the operator's confirmed secret under the same id and lose it, so
131
+ // the connection is flagged instead and its deliveries are refused by name.
132
+ logger.warn(
133
+ `Webhook secret for connection ${conn.id} is stored but unreadable — deliveries are ` +
134
+ 'refused until the secret store returns it again; the stored secret is left untouched',
135
+ );
136
+ conn.webhookSecret = undefined;
137
+ conn.webhookSecretUnreadable = true;
138
+ }
139
+ }
115
140
  this.connections.push(conn);
141
+ await this.backfillWebhookSecret(conn);
116
142
  }
117
143
  }
118
144
  if (this.connections.length > 0) {
@@ -157,6 +183,30 @@ export class ConnectionManager {
157
183
  }
158
184
  }
159
185
 
186
+ /**
187
+ * Connections stored before webhook verification existed carry no secret. Nothing in
188
+ * gitops can create the webhook at the provider (neither provider implementation nor
189
+ * the Gitea/GitLab clients expose a hooks endpoint), so the secret is minted here and
190
+ * the connection is marked 'unverified': its deliveries stay refused until an operator
191
+ * copies the secret into the provider's webhook configuration and confirms it.
192
+ */
193
+ private async backfillWebhookSecret(conn: interfaces.data.IProviderConnection): Promise<void> {
194
+ // A stored-but-unreadable secret is not a missing one. Minting here would write a new
195
+ // secret over the same keychain id and destroy confirmed operator material because of
196
+ // a transient read failure, so this connection is left exactly as it is on disk.
197
+ if (conn.webhookSecretUnreadable) return;
198
+ if (conn.webhookSecret && conn.webhookStatus) return;
199
+ if (!conn.webhookSecret) {
200
+ conn.webhookSecret = mintWebhookSecret();
201
+ }
202
+ conn.webhookStatus = 'unverified';
203
+ await this.persistConnection(conn);
204
+ logger.warn(
205
+ `Connection "${conn.name}" had no webhook secret — minted one and marked the webhook unverified. ` +
206
+ 'Webhook deliveries are refused until the secret is set at the provider and confirmed.',
207
+ );
208
+ }
209
+
160
210
  private async persistConnection(conn: interfaces.data.IProviderConnection): Promise<void> {
161
211
  // Store real token in keychain
162
212
  await this.smartSecret.setSecret(conn.id, conn.token);
@@ -165,11 +215,29 @@ export class ConnectionManager {
165
215
  } else {
166
216
  await this.smartSecret.deleteSecret(getRegistryTokenSecretId(conn.id));
167
217
  }
218
+ // A connection whose secret is stored but unreadable keeps both its keychain entry and
219
+ // its sentinel: writing would replace material we could not read, deleting would throw
220
+ // it away. Every other persist path (status updates, health checks) runs through here,
221
+ // so this is where the stored secret survives a secret-store outage.
222
+ if (conn.webhookSecretUnreadable) {
223
+ // nothing to write — the stored secret and sentinel stay as they are
224
+ } else if (conn.webhookSecret) {
225
+ await this.smartSecret.setSecret(getWebhookSecretId(conn.id), conn.webhookSecret);
226
+ } else {
227
+ await this.smartSecret.deleteSecret(getWebhookSecretId(conn.id));
228
+ }
168
229
  // Save JSON with sentinel value
169
230
  const jsonConn = {
170
231
  ...conn,
171
232
  token: `${KEYCHAIN_PREFIX}${conn.id}`,
172
233
  registryToken: conn.registryToken ? `${KEYCHAIN_PREFIX}${getRegistryTokenSecretId(conn.id)}` : undefined,
234
+ webhookSecret:
235
+ conn.webhookSecret || conn.webhookSecretUnreadable
236
+ ? `${KEYCHAIN_PREFIX}${getWebhookSecretId(conn.id)}`
237
+ : undefined,
238
+ // Runtime-only flag: it describes this process's view of the secret store, not the
239
+ // connection, and persisting it would outlive the outage that caused it.
240
+ webhookSecretUnreadable: undefined,
173
241
  };
174
242
  await this.storageManager.setJSON(`${CONNECTIONS_PREFIX}${conn.id}.json`, jsonConn);
175
243
  }
@@ -177,15 +245,26 @@ export class ConnectionManager {
177
245
  private async removeConnection(id: string): Promise<void> {
178
246
  await this.smartSecret.deleteSecret(id);
179
247
  await this.smartSecret.deleteSecret(getRegistryTokenSecretId(id));
248
+ await this.smartSecret.deleteSecret(getWebhookSecretId(id));
180
249
  await this.storageManager.delete(`${CONNECTIONS_PREFIX}${id}.json`);
181
250
  }
182
251
 
183
- getConnections(): interfaces.data.IProviderConnection[] {
184
- return this.connections.map((c) => ({
185
- ...c,
252
+ /**
253
+ * The shape handed to API callers: every secret is masked, never the real value.
254
+ */
255
+ private toPublicConnection(
256
+ conn: interfaces.data.IProviderConnection,
257
+ ): interfaces.data.IProviderConnection {
258
+ return {
259
+ ...conn,
186
260
  token: '***',
187
- registryToken: c.registryToken ? '***' : undefined,
188
- }));
261
+ registryToken: conn.registryToken ? '***' : undefined,
262
+ webhookSecret: conn.webhookSecret ? '***' : undefined,
263
+ };
264
+ }
265
+
266
+ getConnections(): interfaces.data.IProviderConnection[] {
267
+ return this.connections.map((c) => this.toPublicConnection(c));
189
268
  }
190
269
 
191
270
  getConnection(id: string): interfaces.data.IProviderConnection | undefined {
@@ -214,11 +293,15 @@ export class ConnectionManager {
214
293
  registryUrl: registryUrl || undefined,
215
294
  registryUsername: registryUsername || undefined,
216
295
  registryToken: registryToken || undefined,
296
+ // Minted with the connection so the operator can paste it into the provider's
297
+ // webhook form right away; no earlier provider state can hold a stale secret.
298
+ webhookSecret: mintWebhookSecret(),
299
+ webhookStatus: 'active',
217
300
  };
218
301
  this.connections.push(connection);
219
302
  await this.persistConnection(connection);
220
303
  logger.success(`Connection created: ${name} (${providerType})`);
221
- return { ...connection, token: '***', registryToken: connection.registryToken ? '***' : undefined };
304
+ return this.toPublicConnection(connection);
222
305
  }
223
306
 
224
307
  async updateConnection(
@@ -238,7 +321,7 @@ export class ConnectionManager {
238
321
  if (updates.registryUsername !== undefined) conn.registryUsername = updates.registryUsername || undefined;
239
322
  if (updates.registryToken !== undefined) conn.registryToken = updates.registryToken || undefined;
240
323
  await this.persistConnection(conn);
241
- return { ...conn, token: '***', registryToken: conn.registryToken ? '***' : undefined };
324
+ return this.toPublicConnection(conn);
242
325
  }
243
326
 
244
327
  async deleteConnection(id: string): Promise<void> {
@@ -255,7 +338,66 @@ export class ConnectionManager {
255
338
  conn.status = paused ? 'paused' : 'disconnected';
256
339
  await this.persistConnection(conn);
257
340
  logger.info(`Connection ${paused ? 'paused' : 'resumed'}: ${conn.name}`);
258
- return { ...conn, token: '***', registryToken: conn.registryToken ? '***' : undefined };
341
+ return this.toPublicConnection(conn);
342
+ }
343
+
344
+ /**
345
+ * Reveals the webhook secret to an already authenticated caller — the operator has to
346
+ * transfer it into the provider's webhook configuration by hand, because no provider
347
+ * API surface in this app creates webhooks.
348
+ */
349
+ /**
350
+ * A stored-but-unreadable secret is reported as such, so an operator sees a secret-store
351
+ * outage instead of a connection that appears to have lost its secret.
352
+ */
353
+ private assertWebhookSecretReadable(conn: interfaces.data.IProviderConnection): void {
354
+ if (conn.webhookSecretUnreadable) {
355
+ throw new Error(
356
+ `Webhook secret for connection ${conn.id} is stored but unreadable — the secret store did not return it`,
357
+ );
358
+ }
359
+ }
360
+
361
+ getWebhookSecret(id: string): { secret: string; status: interfaces.data.TWebhookStatus } {
362
+ const conn = this.connections.find((c) => c.id === id);
363
+ if (!conn) throw new Error(`Connection not found: ${id}`);
364
+ this.assertWebhookSecretReadable(conn);
365
+ if (!conn.webhookSecret) throw new Error(`Connection has no webhook secret: ${id}`);
366
+ return { secret: conn.webhookSecret, status: conn.webhookStatus ?? 'unverified' };
367
+ }
368
+
369
+ /**
370
+ * Mints a fresh webhook secret. The provider still holds the previous one, so the
371
+ * connection goes back to 'unverified' until the new secret is confirmed.
372
+ */
373
+ async rotateWebhookSecret(
374
+ id: string,
375
+ ): Promise<{ secret: string; status: interfaces.data.TWebhookStatus }> {
376
+ const conn = this.connections.find((c) => c.id === id);
377
+ if (!conn) throw new Error(`Connection not found: ${id}`);
378
+ conn.webhookSecret = mintWebhookSecret();
379
+ conn.webhookStatus = 'unverified';
380
+ // Rotation is the operator's explicit decision to replace the stored secret, which is
381
+ // also the way out of an unreadable one — unlike the automatic backfill.
382
+ conn.webhookSecretUnreadable = undefined;
383
+ await this.persistConnection(conn);
384
+ logger.info(`Webhook secret rotated for connection "${conn.name}" — webhook is unverified again`);
385
+ return { secret: conn.webhookSecret, status: conn.webhookStatus };
386
+ }
387
+
388
+ /**
389
+ * Records that the provider side now holds the current secret; deliveries that carry a
390
+ * valid signature are accepted from here on.
391
+ */
392
+ async confirmWebhookSecret(id: string): Promise<{ status: interfaces.data.TWebhookStatus }> {
393
+ const conn = this.connections.find((c) => c.id === id);
394
+ if (!conn) throw new Error(`Connection not found: ${id}`);
395
+ this.assertWebhookSecretReadable(conn);
396
+ if (!conn.webhookSecret) throw new Error(`Connection has no webhook secret: ${id}`);
397
+ conn.webhookStatus = 'active';
398
+ await this.persistConnection(conn);
399
+ logger.success(`Webhook confirmed for connection "${conn.name}"`);
400
+ return { status: conn.webhookStatus };
259
401
  }
260
402
 
261
403
  async testConnection(id: string): Promise<{ ok: boolean; error?: string }> {
@@ -2,30 +2,191 @@ import * as plugins from '../../plugins.js';
2
2
  import { logger } from '../../logging.js';
3
3
  import type { OpsServer } from '../classes.opsserver.js';
4
4
  import * as interfaces from '../../../ts_interfaces/index.js';
5
+ import { requireValidIdentity } from '../helpers/guards.js';
6
+ import {
7
+ checkWebhookConnection,
8
+ readBoundedWebhookBody,
9
+ verifyWebhookDelivery,
10
+ webhookEventHeaderFor,
11
+ webhookSecretHeaderFor,
12
+ type TWebhookRefusal,
13
+ } from '../helpers/webhookverification.js';
14
+
15
+ /** Route pattern of the delivery endpoint. */
16
+ export const webhookRoutePattern = '/webhook/:connectionId';
17
+
18
+ /** The concrete delivery path an operator configures at the provider. */
19
+ export const webhookDeliveryPath = (connectionIdArg: string): string =>
20
+ `/webhook/${connectionIdArg}`;
21
+
22
+ /**
23
+ * HTTP status per refusal. Everything the caller could fix by presenting the right
24
+ * secret is a 401; the rest describes the request itself — except
25
+ * 'webhook-secret-unreadable', which is our own transient fault and is answered 503 so a
26
+ * provider retries instead of an operator rotating a secret that is still correct.
27
+ */
28
+ const refusalStatusCodes: Record<TWebhookRefusal, number> = {
29
+ 'webhook-connection-unknown': 404,
30
+ 'webhook-connection-unverified': 401,
31
+ 'webhook-secret-missing': 401,
32
+ 'webhook-secret-unreadable': 503,
33
+ 'webhook-length-missing': 411,
34
+ 'webhook-body-too-large': 413,
35
+ 'webhook-body-unreadable': 400,
36
+ 'webhook-signature-missing': 401,
37
+ 'webhook-signature-invalid': 401,
38
+ };
5
39
 
6
40
  export class WebhookHandler {
7
- constructor(private opsServerRef: OpsServer) {}
41
+ public typedrouter = new plugins.typedrequest.TypedRouter();
42
+
43
+ constructor(private opsServerRef: OpsServer) {
44
+ this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
45
+ this.registerHandlers();
46
+ }
47
+
48
+ private get connectionManager() {
49
+ return this.opsServerRef.gitopsAppRef.connectionManager;
50
+ }
51
+
52
+ private get actionLog() {
53
+ return this.opsServerRef.gitopsAppRef.actionLog;
54
+ }
55
+
56
+ /**
57
+ * Refuses a delivery. The refusal name is the whole answer — no payload is echoed and
58
+ * nothing about the presented or expected secret is logged.
59
+ */
60
+ private refuse(
61
+ refusalArg: TWebhookRefusal,
62
+ connectionIdArg: string,
63
+ connectionNameArg?: string,
64
+ ): Response {
65
+ const target = connectionNameArg
66
+ ? `connection ${connectionNameArg} (${connectionIdArg})`
67
+ : `connection ${connectionIdArg}`;
68
+ logger.warn(`Webhook refused: ${refusalArg} for ${target}`);
69
+ return new Response(JSON.stringify({ error: refusalArg }), {
70
+ status: refusalStatusCodes[refusalArg],
71
+ headers: { 'Content-Type': 'application/json' },
72
+ });
73
+ }
74
+
75
+ private webhookSettingsFor(
76
+ connectionIdArg: string,
77
+ ): interfaces.data.IWebhookSettings {
78
+ const connection = this.connectionManager.getConnection(connectionIdArg);
79
+ if (!connection) {
80
+ throw new plugins.typedrequest.TypedResponseError(`Connection not found: ${connectionIdArg}`);
81
+ }
82
+ const { secret, status } = this.connectionManager.getWebhookSecret(connectionIdArg);
83
+ return {
84
+ connectionId: connection.id,
85
+ providerType: connection.providerType,
86
+ deliveryPath: webhookDeliveryPath(connection.id),
87
+ headerName: webhookSecretHeaderFor(connection.providerType),
88
+ secret,
89
+ status,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Operator-facing side of the webhook: reveal, rotate and confirm the shared secret.
95
+ * Neither provider implementation creates webhooks through the API, so the secret is
96
+ * transferred into the provider's webhook configuration by hand.
97
+ */
98
+ private registerHandlers(): void {
99
+ this.typedrouter.addTypedHandler(
100
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetWebhookSettings>(
101
+ 'getWebhookSettings',
102
+ async (dataArg) => {
103
+ await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
104
+ return { webhookSettings: this.webhookSettingsFor(dataArg.connectionId) };
105
+ },
106
+ ),
107
+ );
108
+
109
+ this.typedrouter.addTypedHandler(
110
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RotateWebhookSecret>(
111
+ 'rotateWebhookSecret',
112
+ async (dataArg) => {
113
+ await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
114
+ await this.connectionManager.rotateWebhookSecret(dataArg.connectionId);
115
+ const webhookSettings = this.webhookSettingsFor(dataArg.connectionId);
116
+ this.actionLog.append({
117
+ actionType: 'update',
118
+ entityType: 'connection',
119
+ entityId: dataArg.connectionId,
120
+ entityName: this.connectionManager.getConnection(dataArg.connectionId)?.name || dataArg.connectionId,
121
+ details: 'Rotated webhook secret — webhook is unverified until the new secret is set at the provider',
122
+ username: dataArg.identity.username,
123
+ });
124
+ return { webhookSettings };
125
+ },
126
+ ),
127
+ );
128
+
129
+ this.typedrouter.addTypedHandler(
130
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ConfirmWebhookSecret>(
131
+ 'confirmWebhookSecret',
132
+ async (dataArg) => {
133
+ await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
134
+ const result = await this.connectionManager.confirmWebhookSecret(dataArg.connectionId);
135
+ this.actionLog.append({
136
+ actionType: 'update',
137
+ entityType: 'connection',
138
+ entityId: dataArg.connectionId,
139
+ entityName: this.connectionManager.getConnection(dataArg.connectionId)?.name || dataArg.connectionId,
140
+ details: 'Confirmed the webhook secret is configured at the provider',
141
+ username: dataArg.identity.username,
142
+ });
143
+ return result;
144
+ },
145
+ ),
146
+ );
147
+ }
8
148
 
9
149
  public registerRoutes(typedserver: plugins.typedserver.TypedServer): void {
10
- typedserver.addRoute('/webhook/:connectionId', 'POST', async (ctx) => {
150
+ typedserver.addRoute(webhookRoutePattern, 'POST', async (ctx) => {
11
151
  const connectionId = ctx.params.connectionId;
12
152
 
13
- // Validate connection exists
14
- const connection = this.opsServerRef.gitopsAppRef.connectionManager.getConnection(connectionId);
153
+ // The connection carries the secret, so it is resolved before any body work.
154
+ const connection = this.connectionManager.getConnection(connectionId);
15
155
  if (!connection) {
16
- return new Response(JSON.stringify({ error: 'Connection not found' }), {
17
- status: 404,
18
- headers: { 'Content-Type': 'application/json' },
19
- });
156
+ return this.refuse('webhook-connection-unknown', connectionId);
157
+ }
158
+
159
+ // Everything that does not need the body is decided before buffering it.
160
+ const precondition = checkWebhookConnection(connection);
161
+ if (precondition) {
162
+ return this.refuse(precondition, connectionId, connection.name);
163
+ }
164
+ // The raw bytes are what the provider signed; parsing and re-serializing would
165
+ // change key order and whitespace and invalidate every genuine signature. The body
166
+ // is read through the bounded reader rather than ctx.arrayBuffer(), which buffers
167
+ // whatever an unauthenticated caller sends.
168
+ const body = await readBoundedWebhookBody({
169
+ headers: ctx.headers,
170
+ body: () => ctx.request.body,
171
+ });
172
+ if (!body.ok) {
173
+ return this.refuse(body.refusal, connectionId, connection.name);
174
+ }
175
+
176
+ const verification = verifyWebhookDelivery({
177
+ connection,
178
+ headers: ctx.headers,
179
+ rawBody: body.rawBody,
180
+ });
181
+ if (!verification.ok) {
182
+ return this.refuse(verification.refusal!, connectionId, connection.name);
20
183
  }
21
184
 
22
- // Parse event type from provider-specific headers
23
- const giteaEvent = ctx.headers.get('X-Gitea-Event');
24
- const gitlabEvent = ctx.headers.get('X-Gitlab-Event');
25
- const event = giteaEvent || gitlabEvent || 'unknown';
26
- const provider = giteaEvent ? 'gitea' : gitlabEvent ? 'gitlab' : 'unknown';
185
+ // The provider is the one we have a connection with, never the one the caller claims.
186
+ const provider = connection.providerType;
187
+ const event = ctx.headers.get(webhookEventHeaderFor(provider)) || 'unknown';
27
188
 
28
- logger.info(`Webhook received: ${provider}/${event} for connection ${connection.name} (${connectionId})`);
189
+ logger.info(`Webhook accepted: ${provider}/${event} for connection ${connection.name} (${connectionId})`);
29
190
 
30
191
  // Broadcast to all connected frontends via TypedSocket
31
192
  try {