@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
@@ -0,0 +1,202 @@
1
+ import * as plugins from '../../plugins.js';
2
+ import type * as interfaces from '../../../ts_interfaces/index.js';
3
+ import { GiteaProvider, GitLabProvider } from '../../providers/index.js';
4
+
5
+ /**
6
+ * Largest webhook delivery gitops buffers. Gitea and GitLab push payloads stay far below
7
+ * this. The bound is enforced while the body is being read (see readBoundedWebhookBody),
8
+ * so it caps what an unauthenticated caller can make the process hold in memory even when
9
+ * the delivery declares a smaller Content-Length than it sends.
10
+ */
11
+ export const webhookMaxBodyBytes = 1024 * 1024;
12
+
13
+ /**
14
+ * Stable refusal names. They are returned to the caller and written to the log, so an
15
+ * operator can tell a stale secret from a missing one without inspecting payloads.
16
+ */
17
+ export type TWebhookRefusal =
18
+ | 'webhook-connection-unknown'
19
+ | 'webhook-connection-unverified'
20
+ | 'webhook-secret-missing'
21
+ | 'webhook-secret-unreadable'
22
+ | 'webhook-length-missing'
23
+ | 'webhook-body-too-large'
24
+ | 'webhook-body-unreadable'
25
+ | 'webhook-signature-missing'
26
+ | 'webhook-signature-invalid';
27
+
28
+ export interface IWebhookVerificationResult {
29
+ ok: boolean;
30
+ refusal?: TWebhookRefusal;
31
+ }
32
+
33
+ /** Outcome of reading a delivery body: the raw bytes, or the reason the read was refused. */
34
+ export type TWebhookBodyResult =
35
+ | { ok: true; rawBody: Uint8Array }
36
+ | { ok: false; refusal: TWebhookRefusal };
37
+
38
+ /** Non-negative integer, nothing else — a duplicated or padded header value is not one. */
39
+ const contentLengthPattern = /^\d+$/;
40
+
41
+ const hexPattern = /^[0-9a-f]+$/;
42
+
43
+ /**
44
+ * Compares two strings without leaking their content through timing. Length differs
45
+ * openly: timingSafeEqual throws on unequal buffer lengths, and the length of a
46
+ * signature header is observable anyway.
47
+ */
48
+ const timingSafeEqualStrings = (leftArg: string, rightArg: string): boolean => {
49
+ const left = plugins.Buffer.from(leftArg, 'utf8');
50
+ const right = plugins.Buffer.from(rightArg, 'utf8');
51
+ if (left.length !== right.length) return false;
52
+ return plugins.crypto.timingSafeEqual(left, right);
53
+ };
54
+
55
+ /**
56
+ * The header that names the delivered event for a given provider.
57
+ */
58
+ export const webhookEventHeaderFor = (
59
+ providerTypeArg: interfaces.data.TProviderType,
60
+ ): string => {
61
+ return providerTypeArg === 'gitea'
62
+ ? GiteaProvider.webhookEventHeader
63
+ : GitLabProvider.webhookEventHeader;
64
+ };
65
+
66
+ /**
67
+ * The header a provider uses to prove it holds the webhook secret.
68
+ */
69
+ export const webhookSecretHeaderFor = (
70
+ providerTypeArg: interfaces.data.TProviderType,
71
+ ): string => {
72
+ return providerTypeArg === 'gitea'
73
+ ? GiteaProvider.webhookSignatureHeader
74
+ : GitLabProvider.webhookTokenHeader;
75
+ };
76
+
77
+ /**
78
+ * Checks everything that does not depend on the request body, so an unusable
79
+ * connection is refused before a single byte is buffered.
80
+ */
81
+ export const checkWebhookConnection = (
82
+ connectionArg: interfaces.data.IProviderConnection,
83
+ ): TWebhookRefusal | null => {
84
+ // The secret store holds this connection's secret but would not return it. Nothing can
85
+ // be verified against it, and it must not be replaced by a fresh one, so deliveries are
86
+ // refused under their own name until the secret store recovers.
87
+ if (connectionArg.webhookSecretUnreadable) return 'webhook-secret-unreadable';
88
+ if (!connectionArg.webhookSecret) return 'webhook-secret-missing';
89
+ // A connection whose secret was minted by the backfill is not yet known to the
90
+ // provider, so nothing it delivers can be trusted until the operator confirms it.
91
+ if (connectionArg.webhookStatus !== 'active') return 'webhook-connection-unverified';
92
+ return null;
93
+ };
94
+
95
+ /**
96
+ * Verifies a delivery against the connection's webhook secret.
97
+ *
98
+ * Gitea: HMAC-SHA256 over the raw delivery bytes, hex, in X-Gitea-Signature.
99
+ * GitLab: the secret token echoed verbatim in X-Gitlab-Token.
100
+ *
101
+ * rawBody must be the bytes as received — re-serializing the parsed JSON changes key
102
+ * order and whitespace and would invalidate every genuine signature.
103
+ */
104
+ export const verifyWebhookDelivery = (optionsArg: {
105
+ connection: interfaces.data.IProviderConnection;
106
+ headers: Headers;
107
+ rawBody: Uint8Array;
108
+ }): IWebhookVerificationResult => {
109
+ const precondition = checkWebhookConnection(optionsArg.connection);
110
+ if (precondition) return { ok: false, refusal: precondition };
111
+ const secret = optionsArg.connection.webhookSecret!;
112
+
113
+ if (optionsArg.connection.providerType === 'gitea') {
114
+ const signature = optionsArg.headers.get(GiteaProvider.webhookSignatureHeader);
115
+ if (!signature) return { ok: false, refusal: 'webhook-signature-missing' };
116
+ const expected = plugins.crypto.createHmac('sha256', secret).update(optionsArg.rawBody).digest();
117
+ const provided = signature.trim().toLowerCase();
118
+ // Length and alphabet are checked before decoding: Buffer.from(..., 'hex') silently
119
+ // truncates malformed input, and timingSafeEqual throws on a length mismatch.
120
+ if (provided.length !== expected.length * 2 || !hexPattern.test(provided)) {
121
+ return { ok: false, refusal: 'webhook-signature-invalid' };
122
+ }
123
+ const providedBuffer = plugins.Buffer.from(provided, 'hex');
124
+ if (!plugins.crypto.timingSafeEqual(providedBuffer, expected)) {
125
+ return { ok: false, refusal: 'webhook-signature-invalid' };
126
+ }
127
+ return { ok: true };
128
+ }
129
+
130
+ const token = optionsArg.headers.get(GitLabProvider.webhookTokenHeader);
131
+ if (!token) return { ok: false, refusal: 'webhook-signature-missing' };
132
+ if (!timingSafeEqualStrings(token, secret)) {
133
+ return { ok: false, refusal: 'webhook-signature-invalid' };
134
+ }
135
+ return { ok: true };
136
+ };
137
+
138
+ /**
139
+ * Reads a delivery body under a hard byte budget.
140
+ *
141
+ * Two separate guards, because each covers what the other cannot:
142
+ * - Content-Length is required. Gitea and GitLab always declare one; a delivery without a
143
+ * parsable length is chunked or headerless, and reading it would mean buffering a stream
144
+ * of unknown size from an unauthenticated caller. It is refused as
145
+ * 'webhook-length-missing' before the body is touched at all.
146
+ * - The declared length is the caller's claim, never a fact, so the stream is consumed
147
+ * chunk by chunk against a running budget. A forged short Content-Length is refused the
148
+ * moment the bound is crossed, with only the bound buffered.
149
+ */
150
+ export const readBoundedWebhookBody = async (optionsArg: {
151
+ headers: Headers;
152
+ /** Lazy on purpose: not called when the delivery is refused on its headers alone. */
153
+ body: () => ReadableStream<Uint8Array> | null;
154
+ maxBytes?: number;
155
+ }): Promise<TWebhookBodyResult> => {
156
+ const maxBytes = optionsArg.maxBytes ?? webhookMaxBodyBytes;
157
+ const declared = optionsArg.headers.get('content-length');
158
+ if (declared === null || !contentLengthPattern.test(declared.trim())) {
159
+ return { ok: false, refusal: 'webhook-length-missing' };
160
+ }
161
+ const declaredLength = Number(declared.trim());
162
+ if (declaredLength > maxBytes) {
163
+ return { ok: false, refusal: 'webhook-body-too-large' };
164
+ }
165
+
166
+ const stream = optionsArg.body();
167
+ if (!stream) {
168
+ // No stream at all is only consistent with a delivery that declared no bytes.
169
+ return declaredLength === 0
170
+ ? { ok: true, rawBody: new Uint8Array(0) }
171
+ : { ok: false, refusal: 'webhook-body-unreadable' };
172
+ }
173
+
174
+ const reader = stream.getReader();
175
+ const chunks: Uint8Array[] = [];
176
+ let received = 0;
177
+ try {
178
+ while (true) {
179
+ const { done, value } = await reader.read();
180
+ if (done) break;
181
+ if (!value) continue;
182
+ received += value.byteLength;
183
+ if (received > maxBytes) {
184
+ // Stop pulling: the rest of the body is never buffered.
185
+ await reader.cancel().catch(() => undefined);
186
+ return { ok: false, refusal: 'webhook-body-too-large' };
187
+ }
188
+ chunks.push(value);
189
+ }
190
+ } catch {
191
+ await reader.cancel().catch(() => undefined);
192
+ return { ok: false, refusal: 'webhook-body-unreadable' };
193
+ }
194
+
195
+ const rawBody = new Uint8Array(received);
196
+ let offset = 0;
197
+ for (const chunk of chunks) {
198
+ rawBody.set(chunk, offset);
199
+ offset += chunk.byteLength;
200
+ }
201
+ return { ok: true, rawBody };
202
+ };
package/ts/plugins.ts CHANGED
@@ -8,9 +8,10 @@ import * as fs from 'node:fs/promises';
8
8
  import * as os from 'node:os';
9
9
  import * as nodeUrl from 'node:url';
10
10
  import * as childProcess from 'node:child_process';
11
+ import * as crypto from 'node:crypto';
11
12
  import { Buffer } from 'node:buffer';
12
13
 
13
- export { path, fs, os, nodeUrl, childProcess, Buffer };
14
+ export { path, fs, os, nodeUrl, childProcess, crypto, Buffer };
14
15
 
15
16
  // TypedRequest/TypedServer infrastructure
16
17
  import * as typedrequest from '@api.global/typedrequest';
@@ -6,6 +6,17 @@ import { BaseProvider, type IReleaseAssetPayload, type IReleaseAssetTransferOpti
6
6
  * Gitea API v1 provider implementation
7
7
  */
8
8
  export class GiteaProvider extends BaseProvider {
9
+ /**
10
+ * Gitea signs a webhook delivery by HMAC-SHA256 over the raw request body with the
11
+ * webhook secret and sends the digest as lowercase hex in this header.
12
+ *
13
+ * Spelled the way Gitea documents it: header lookup is case-insensitive, so the same
14
+ * constant serves the verification and the operator-facing webhook dialog.
15
+ */
16
+ public static readonly webhookSignatureHeader = 'X-Gitea-Signature';
17
+ /** Header naming the delivered event, e.g. 'push'. */
18
+ public static readonly webhookEventHeader = 'X-Gitea-Event';
19
+
9
20
  private client: plugins.giteaClient.GiteaClient;
10
21
 
11
22
  constructor(connectionId: string, baseUrl: string, token: string, groupFilterId?: string) {
@@ -6,6 +6,17 @@ import { BaseProvider, type IReleaseAssetPayload, type IReleaseAssetTransferOpti
6
6
  * GitLab API v4 provider implementation
7
7
  */
8
8
  export class GitLabProvider extends BaseProvider {
9
+ /**
10
+ * GitLab does not sign webhook bodies: it echoes the configured secret token
11
+ * verbatim in this header, so the comparison is against the secret itself.
12
+ *
13
+ * Spelled the way GitLab documents it: header lookup is case-insensitive, so the same
14
+ * constant serves the verification and the operator-facing webhook dialog.
15
+ */
16
+ public static readonly webhookTokenHeader = 'X-Gitlab-Token';
17
+ /** Header naming the delivered event, e.g. 'Push Hook'. */
18
+ public static readonly webhookEventHeader = 'X-Gitlab-Event';
19
+
9
20
  private client: plugins.gitlabClient.GitLabClient;
10
21
 
11
22
  constructor(connectionId: string, baseUrl: string, token: string, groupFilterId?: string) {
@@ -1,5 +1,13 @@
1
1
  export type TProviderType = 'gitea' | 'gitlab';
2
2
 
3
+ /**
4
+ * Whether the provider side of a connection is known to hold the webhook secret.
5
+ * 'unverified' connections were created before gitops minted webhook secrets: the
6
+ * operator still has to copy the minted secret into the provider's webhook config,
7
+ * so their deliveries are refused until they confirm it.
8
+ */
9
+ export type TWebhookStatus = 'active' | 'unverified';
10
+
3
11
  export interface IProviderConnection {
4
12
  id: string;
5
13
  name: string;
@@ -13,4 +21,25 @@ export interface IProviderConnection {
13
21
  registryUrl?: string; // Optional OCI registry host/base URL when it differs from provider baseUrl
14
22
  registryUsername?: string; // Optional OCI registry username; provider token is used first when omitted
15
23
  registryToken?: string; // Optional OCI registry token/password; stored in keychain when configured
24
+ webhookSecret?: string; // Shared secret for incoming provider webhooks; stored in keychain, masked on read
25
+ webhookStatus?: TWebhookStatus; // 'active' once the provider side is known to hold the secret
26
+ /**
27
+ * Set while the secret store holds this connection's webhook secret but did not return
28
+ * it. Runtime-only and never persisted: the stored sentinel and status stay valid, so a
29
+ * failed read must neither mint a replacement nor overwrite the stored material.
30
+ */
31
+ webhookSecretUnreadable?: boolean;
32
+ }
33
+
34
+ /**
35
+ * Everything an operator needs to configure the webhook at the provider.
36
+ * Handed out only to an authenticated caller — it carries the real secret.
37
+ */
38
+ export interface IWebhookSettings {
39
+ connectionId: string;
40
+ providerType: TProviderType;
41
+ deliveryPath: string; // path the provider posts to, e.g. '/webhook/<id>'
42
+ headerName: string; // header carrying the signature (Gitea) or the token (GitLab)
43
+ secret: string;
44
+ status: TWebhookStatus;
16
45
  }
@@ -16,3 +16,45 @@ export interface IReq_WebhookNotification extends plugins.typedrequestInterfaces
16
16
  ok: boolean;
17
17
  };
18
18
  }
19
+
20
+ export interface IReq_GetWebhookSettings extends plugins.typedrequestInterfaces.implementsTR<
21
+ plugins.typedrequestInterfaces.ITypedRequest,
22
+ IReq_GetWebhookSettings
23
+ > {
24
+ method: 'getWebhookSettings';
25
+ request: {
26
+ identity: data.IIdentity;
27
+ connectionId: string;
28
+ };
29
+ response: {
30
+ webhookSettings: data.IWebhookSettings;
31
+ };
32
+ }
33
+
34
+ export interface IReq_RotateWebhookSecret extends plugins.typedrequestInterfaces.implementsTR<
35
+ plugins.typedrequestInterfaces.ITypedRequest,
36
+ IReq_RotateWebhookSecret
37
+ > {
38
+ method: 'rotateWebhookSecret';
39
+ request: {
40
+ identity: data.IIdentity;
41
+ connectionId: string;
42
+ };
43
+ response: {
44
+ webhookSettings: data.IWebhookSettings;
45
+ };
46
+ }
47
+
48
+ export interface IReq_ConfirmWebhookSecret extends plugins.typedrequestInterfaces.implementsTR<
49
+ plugins.typedrequestInterfaces.ITypedRequest,
50
+ IReq_ConfirmWebhookSecret
51
+ > {
52
+ method: 'confirmWebhookSecret';
53
+ request: {
54
+ identity: data.IIdentity;
55
+ connectionId: string;
56
+ };
57
+ response: {
58
+ status: data.TWebhookStatus;
59
+ };
60
+ }
@@ -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
  }
@@ -205,6 +205,52 @@ export const createConnectionAction = connectionsStatePart.createAction<{
205
205
  }
206
206
  });
207
207
 
208
+ /**
209
+ * Webhook secrets are fetched on demand and handed straight to the caller — they are
210
+ * never put into a state part, where every subscriber would see them.
211
+ */
212
+ export const fetchWebhookSettings = async (
213
+ connectionIdArg: string,
214
+ ): Promise<interfaces.data.IWebhookSettings> => {
215
+ const context = getActionContext();
216
+ const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
217
+ interfaces.requests.IReq_GetWebhookSettings
218
+ >('/typedrequest', 'getWebhookSettings');
219
+ const response = await typedRequest.fire({
220
+ identity: context.identity!,
221
+ connectionId: connectionIdArg,
222
+ });
223
+ return response.webhookSettings;
224
+ };
225
+
226
+ export const rotateWebhookSecret = async (
227
+ connectionIdArg: string,
228
+ ): Promise<interfaces.data.IWebhookSettings> => {
229
+ const context = getActionContext();
230
+ const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
231
+ interfaces.requests.IReq_RotateWebhookSecret
232
+ >('/typedrequest', 'rotateWebhookSecret');
233
+ const response = await typedRequest.fire({
234
+ identity: context.identity!,
235
+ connectionId: connectionIdArg,
236
+ });
237
+ return response.webhookSettings;
238
+ };
239
+
240
+ export const confirmWebhookSecret = async (
241
+ connectionIdArg: string,
242
+ ): Promise<interfaces.data.TWebhookStatus> => {
243
+ const context = getActionContext();
244
+ const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
245
+ interfaces.requests.IReq_ConfirmWebhookSecret
246
+ >('/typedrequest', 'confirmWebhookSecret');
247
+ const response = await typedRequest.fire({
248
+ identity: context.identity!,
249
+ connectionId: connectionIdArg,
250
+ });
251
+ return response.status;
252
+ };
253
+
208
254
  export const testConnectionAction = connectionsStatePart.createAction<{
209
255
  connectionId: string;
210
256
  }>(async (statePartArg, dataArg) => {
@@ -1,5 +1,6 @@
1
1
  import * as plugins from '../../../plugins.js';
2
2
  import * as appstate from '../../../appstate.js';
3
+ import type * as interfaces from '../../../../ts_interfaces/index.js';
3
4
  import { viewHostCss } from '../../shared/index.js';
4
5
  import {
5
6
  DeesElement,
@@ -65,6 +66,7 @@ export class GitopsViewConnections extends DeesElement {
65
66
  'Group Filter': item.groupFilter || '-',
66
67
  Registry: item.registryUrl || '-',
67
68
  'Registry User': item.registryUsername || '-',
69
+ Webhook: item.webhookStatus || 'unverified',
68
70
  Status: item.status,
69
71
  Created: new Date(item.createdAt).toLocaleDateString(),
70
72
  })}
@@ -86,6 +88,12 @@ export class GitopsViewConnections extends DeesElement {
86
88
  );
87
89
  },
88
90
  },
91
+ {
92
+ name: 'Webhook',
93
+ iconName: 'lucide:webhook',
94
+ type: ['inRow', 'contextmenu'],
95
+ actionFunc: async ({ item }: any) => { await this.showWebhookSettings(item); },
96
+ },
89
97
  {
90
98
  name: 'Pause/Resume',
91
99
  iconName: 'lucide:pauseCircle',
@@ -149,6 +157,87 @@ export class GitopsViewConnections extends DeesElement {
149
157
  await appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null);
150
158
  }
151
159
 
160
+ /**
161
+ * Shows the shared secret the provider has to send with every delivery. gitops cannot
162
+ * create the webhook through the provider API, so the operator pastes the secret into
163
+ * the provider's webhook form and confirms it here.
164
+ */
165
+ private async showWebhookSettings(item: any) {
166
+ let settings: interfaces.data.IWebhookSettings;
167
+ try {
168
+ settings = await appstate.fetchWebhookSettings(item.id);
169
+ } catch (err) {
170
+ console.error('Failed to load webhook settings:', err);
171
+ await plugins.deesCatalog.DeesModal.createAndShow({
172
+ heading: 'Webhook',
173
+ content: html`<p style="color: #fff;">Could not load the webhook settings for "${item.name}".</p>`,
174
+ menuOptions: [{ name: 'Close', action: async (modal: any) => { modal.destroy(); } }],
175
+ });
176
+ return;
177
+ }
178
+ const deliveryUrl = `${window.location.origin}${settings.deliveryPath}`;
179
+ const secretLabel = settings.providerType === 'gitea' ? 'Secret' : 'Secret Token';
180
+ await plugins.deesCatalog.DeesModal.createAndShow({
181
+ heading: `Webhook — ${item.name}`,
182
+ content: html`
183
+ <style>
184
+ .webhook-row { margin-bottom: 12px; color: #fff; }
185
+ .webhook-label { font-size: 12px; color: #888; }
186
+ .webhook-value { font-family: monospace; word-break: break-all; }
187
+ .webhook-hint { font-size: 13px; color: #888; margin-top: 16px; }
188
+ </style>
189
+ <div class="webhook-row">
190
+ <div class="webhook-label">Payload URL</div>
191
+ <div class="webhook-value">${deliveryUrl}</div>
192
+ </div>
193
+ <div class="webhook-row">
194
+ <div class="webhook-label">${secretLabel}</div>
195
+ <div class="webhook-value">${settings.secret}</div>
196
+ </div>
197
+ <div class="webhook-row">
198
+ <div class="webhook-label">Verification header</div>
199
+ <div class="webhook-value">${settings.headerName}</div>
200
+ </div>
201
+ <div class="webhook-row">
202
+ <div class="webhook-label">Status</div>
203
+ <div class="webhook-value">${settings.status}</div>
204
+ </div>
205
+ <div class="webhook-hint">
206
+ Set this ${secretLabel.toLowerCase()} on the webhook in ${settings.providerType === 'gitea' ? 'Gitea' : 'GitLab'},
207
+ then choose "Mark configured". Deliveries are refused while the webhook is unverified or
208
+ signed with a different secret.
209
+ </div>
210
+ `,
211
+ menuOptions: [
212
+ { name: 'Close', action: async (modal: any) => { modal.destroy(); } },
213
+ {
214
+ name: 'Rotate Secret',
215
+ action: async (modal: any) => {
216
+ try {
217
+ await appstate.rotateWebhookSecret(item.id);
218
+ } catch (err) {
219
+ console.error('Failed to rotate webhook secret:', err);
220
+ }
221
+ modal.destroy();
222
+ await this.refresh();
223
+ },
224
+ },
225
+ {
226
+ name: 'Mark Configured',
227
+ action: async (modal: any) => {
228
+ try {
229
+ await appstate.confirmWebhookSecret(item.id);
230
+ } catch (err) {
231
+ console.error('Failed to confirm webhook secret:', err);
232
+ }
233
+ modal.destroy();
234
+ await this.refresh();
235
+ },
236
+ },
237
+ ],
238
+ });
239
+ }
240
+
152
241
  private async editConnection(item: any) {
153
242
  await plugins.deesCatalog.DeesModal.createAndShow({
154
243
  heading: 'Edit Connection',
package/readme.todo.md DELETED
@@ -1,3 +0,0 @@
1
- # GitOps TODOs
2
-
3
- - [ ] Webhook HMAC signature verification (X-Gitea-Signature / X-Gitlab-Token) — currently accepts all POSTs