@shipfox/api-integration-gitea 5.0.0 → 7.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-integration-gitea",
3
3
  "license": "MIT",
4
- "version": "5.0.0",
4
+ "version": "7.1.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -23,31 +23,20 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "drizzle-orm": "^0.45.2",
26
- "@shipfox/api-auth-context": "5.0.0",
27
- "@shipfox/api-integration-core-dto": "5.0.0",
28
- "@shipfox/api-integration-gitea-dto": "5.0.0",
26
+ "@shipfox/api-auth-context": "7.1.0",
27
+ "@shipfox/api-integration-core-dto": "6.0.0",
28
+ "@shipfox/api-integration-gitea-dto": "6.0.0",
29
29
  "@shipfox/config": "1.2.2",
30
- "@shipfox/node-drizzle": "0.3.1",
31
- "@shipfox/node-fastify": "0.2.3",
32
- "@shipfox/node-opentelemetry": "0.5.2",
30
+ "@shipfox/node-drizzle": "0.3.2",
31
+ "@shipfox/node-fastify": "0.3.0",
32
+ "@shipfox/node-opentelemetry": "0.6.0",
33
33
  "@shipfox/node-postgres": "0.4.2"
34
34
  },
35
- "devDependencies": {
36
- "@types/pg": "^8.15.5",
37
- "drizzle-kit": "^0.31.10",
38
- "fastify": "^5.3.3",
39
- "fastify-type-provider-zod": "^6.0.0",
40
- "fishery": "^2.4.0",
41
- "@shipfox/biome": "1.8.2",
42
- "@shipfox/swc": "1.2.6",
43
- "@shipfox/ts-config": "1.3.8",
44
- "@shipfox/typescript": "1.1.7",
45
- "@shipfox/vitest": "1.2.3"
46
- },
47
35
  "scripts": {
48
36
  "build": "shipfox-swc",
49
37
  "check": "shipfox-biome-check",
50
38
  "check:fix": "shipfox-biome-check --write",
39
+ "depcruise": "shipfox-depcruise",
51
40
  "test": "shipfox-vitest-run",
52
41
  "test:watch": "shipfox-vitest-watch",
53
42
  "type": "shipfox-tsc-check",
@@ -0,0 +1,83 @@
1
+ import {createHmac, randomUUID} from 'node:crypto';
2
+ import {createStoredWebhookRequest, decodeWebhookBody} from '@shipfox/api-integration-core-dto';
3
+ import {createGiteaWebhookProcessor} from './webhook-processor.js';
4
+
5
+ const WEBHOOK_SECRET = 'test-webhook-secret';
6
+
7
+ describe('Gitea webhook processor', () => {
8
+ it('discards a stored request with an invalid signature before opening a transaction', async () => {
9
+ const coreDb = vi.fn();
10
+ const processor = createGiteaWebhookProcessor({
11
+ coreDb,
12
+ publishSourcePush: vi.fn(),
13
+ recordDeliveryOnly: vi.fn(),
14
+ getIntegrationConnectionById: vi.fn(),
15
+ });
16
+ const request = createStoredWebhookRequest({
17
+ requestId: randomUUID(),
18
+ routeId: 'gitea',
19
+ receivedAt: new Date().toISOString(),
20
+ rawQueryString: '',
21
+ headers: {
22
+ 'x-gitea-delivery': randomUUID(),
23
+ 'x-gitea-event': 'push',
24
+ 'x-gitea-signature': 'not-a-valid-signature',
25
+ },
26
+ body: Buffer.from('{}'),
27
+ });
28
+
29
+ const result = await processor.process(request);
30
+
31
+ expect(result).toMatchObject({outcome: 'discarded', reason: 'invalid_signature'});
32
+ expect(coreDb).not.toHaveBeenCalled();
33
+ });
34
+
35
+ it('discards a stored request missing required provider headers', async () => {
36
+ const processor = createGiteaWebhookProcessor({
37
+ coreDb: vi.fn(),
38
+ publishSourcePush: vi.fn(),
39
+ recordDeliveryOnly: vi.fn(),
40
+ getIntegrationConnectionById: vi.fn(),
41
+ });
42
+ const request = createStoredWebhookRequest({
43
+ requestId: randomUUID(),
44
+ routeId: 'gitea',
45
+ receivedAt: new Date().toISOString(),
46
+ rawQueryString: '',
47
+ headers: {},
48
+ body: Buffer.from('{}'),
49
+ });
50
+
51
+ const result = await processor.process(request);
52
+
53
+ expect(result).toEqual({outcome: 'discarded', reason: 'missing_required_input'});
54
+ });
55
+
56
+ it('preserves the signed raw body from a stored request before reporting malformed JSON', async () => {
57
+ const rawBody = Buffer.from('{"message":"h\u00e9llo"');
58
+ const request = createStoredWebhookRequest({
59
+ requestId: randomUUID(),
60
+ routeId: 'gitea',
61
+ receivedAt: new Date().toISOString(),
62
+ rawQueryString: '',
63
+ headers: {
64
+ 'x-gitea-delivery': randomUUID(),
65
+ 'x-gitea-event': 'push',
66
+ 'x-gitea-signature': createHmac('sha256', WEBHOOK_SECRET).update(rawBody).digest('hex'),
67
+ },
68
+ body: rawBody,
69
+ });
70
+ const processor = createGiteaWebhookProcessor({
71
+ coreDb: () =>
72
+ ({transaction: (callback: (tx: unknown) => Promise<unknown>) => callback({})}) as never,
73
+ publishSourcePush: vi.fn(),
74
+ recordDeliveryOnly: vi.fn(),
75
+ getIntegrationConnectionById: vi.fn(),
76
+ });
77
+
78
+ const result = await processor.process(request);
79
+
80
+ expect(Buffer.from(decodeWebhookBody(request.body))).toEqual(rawBody);
81
+ expect(result).toMatchObject({outcome: 'discarded', reason: 'malformed_payload'});
82
+ });
83
+ });
@@ -0,0 +1,90 @@
1
+ import {Buffer} from 'node:buffer';
2
+ import {
3
+ decodeWebhookBody,
4
+ type GetIntegrationConnectionByIdFn,
5
+ type PublishSourcePushFn,
6
+ type RecordDeliveryOnlyFn,
7
+ type StoredWebhookRequest,
8
+ type WebhookProcessingResult,
9
+ } from '@shipfox/api-integration-core-dto';
10
+ import {verifyHexHmacSignature} from '@shipfox/node-fastify';
11
+ import {logger} from '@shipfox/node-opentelemetry';
12
+ import type {NodePgDatabase} from 'drizzle-orm/node-postgres';
13
+ import {config} from '#config.js';
14
+ import {
15
+ GiteaWebhookMalformedJsonError,
16
+ GiteaWebhookMalformedPushPayloadError,
17
+ handleGiteaWebhook,
18
+ } from '#core/webhook.js';
19
+
20
+ const DELIVERY_HEADER = 'x-gitea-delivery';
21
+ const EVENT_HEADER = 'x-gitea-event';
22
+ const SIGNATURE_HEADER = 'x-gitea-signature';
23
+
24
+ export interface CreateGiteaWebhookProcessorOptions {
25
+ coreDb: () => NodePgDatabase<Record<string, unknown>>;
26
+ publishSourcePush: PublishSourcePushFn;
27
+ recordDeliveryOnly: RecordDeliveryOnlyFn;
28
+ getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
29
+ }
30
+
31
+ export interface GiteaWebhookProcessor {
32
+ process(request: StoredWebhookRequest): Promise<WebhookProcessingResult>;
33
+ }
34
+
35
+ export function createGiteaWebhookProcessor(
36
+ options: CreateGiteaWebhookProcessorOptions,
37
+ ): GiteaWebhookProcessor {
38
+ return {process: (request) => processGiteaWebhookRequest(options, request)};
39
+ }
40
+
41
+ async function processGiteaWebhookRequest(
42
+ options: CreateGiteaWebhookProcessorOptions,
43
+ request: StoredWebhookRequest,
44
+ ): Promise<WebhookProcessingResult> {
45
+ if (request.route_id !== 'gitea') {
46
+ throw new Error(`Gitea processor cannot process ${request.route_id} requests`);
47
+ }
48
+
49
+ const deliveryId = request.headers[DELIVERY_HEADER];
50
+ const event = request.headers[EVENT_HEADER];
51
+ const signature = request.headers[SIGNATURE_HEADER];
52
+ if (!deliveryId || !event || !signature) {
53
+ return {outcome: 'discarded', reason: 'missing_required_input'};
54
+ }
55
+
56
+ const rawBody = Buffer.from(decodeWebhookBody(request.body));
57
+ if (!verifyHexHmacSignature({rawBody, signature, secret: config.GITEA_WEBHOOK_SECRET})) {
58
+ return {outcome: 'discarded', reason: 'invalid_signature', deliveryId};
59
+ }
60
+
61
+ try {
62
+ const result = await options.coreDb().transaction(async (tx) =>
63
+ handleGiteaWebhook({
64
+ tx,
65
+ deliveryId,
66
+ event,
67
+ rawBody: rawBody.toString('utf8'),
68
+ publishSourcePush: options.publishSourcePush,
69
+ recordDeliveryOnly: options.recordDeliveryOnly,
70
+ getIntegrationConnectionById: options.getIntegrationConnectionById,
71
+ }),
72
+ );
73
+ return result.outcome === 'duplicate'
74
+ ? {outcome: 'duplicate', deliveryId}
75
+ : {outcome: 'processed', deliveryId};
76
+ } catch (error) {
77
+ if (error instanceof GiteaWebhookMalformedJsonError) {
78
+ logger().warn({deliveryId, err: error}, 'gitea webhook payload JSON parse failed');
79
+ return {outcome: 'discarded', reason: 'malformed_payload', deliveryId};
80
+ }
81
+ if (error instanceof GiteaWebhookMalformedPushPayloadError) {
82
+ logger().warn(
83
+ {deliveryId, issues: error.issues},
84
+ 'gitea webhook push payload failed schema validation',
85
+ );
86
+ return {outcome: 'discarded', reason: 'unsupported_event', deliveryId};
87
+ }
88
+ throw error;
89
+ }
90
+ }
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import {createGiteaApiClient, type GiteaApiClient} from '#api/client.js';
10
10
  import type {ConnectGiteaConnectionInput} from '#core/connect.js';
11
11
  import {giteaConnectionExternalUrl} from '#core/connection-url.js';
12
12
  import {GiteaSourceControlProvider} from '#core/source-control.js';
13
+ import {createGiteaWebhookProcessor} from '#core/webhook-processor.js';
13
14
  import {closeDb, db} from '#db/db.js';
14
15
  import {migrationsPath} from '#db/migrations.js';
15
16
  import {createGiteaConnectionRoutes} from '#presentation/routes/connections.js';
@@ -32,6 +33,11 @@ export {
32
33
  GiteaOrganizationNotFoundError,
33
34
  } from '#core/errors.js';
34
35
  export {GiteaSourceControlProvider} from '#core/source-control.js';
36
+ export type {
37
+ CreateGiteaWebhookProcessorOptions,
38
+ GiteaWebhookProcessor,
39
+ } from '#core/webhook-processor.js';
40
+ export {createGiteaWebhookProcessor} from '#core/webhook-processor.js';
35
41
  export type {GiteaConnection, UpsertGiteaConnectionParams} from '#db/connections.js';
36
42
  export {
37
43
  getGiteaConnectionByConnectionId,
@@ -56,6 +62,7 @@ export interface CreateGiteaIntegrationProviderOptions {
56
62
 
57
63
  export function createGiteaIntegrationProvider(options: CreateGiteaIntegrationProviderOptions) {
58
64
  const gitea = options.gitea ?? createGiteaApiClient();
65
+ const webhookProcessor = createGiteaWebhookProcessor(options);
59
66
 
60
67
  return {
61
68
  provider: giteaProviderKind,
@@ -77,7 +84,9 @@ export function createGiteaIntegrationProvider(options: CreateGiteaIntegrationPr
77
84
  publishSourcePush: options.publishSourcePush,
78
85
  recordDeliveryOnly: options.recordDeliveryOnly,
79
86
  getIntegrationConnectionById: options.getIntegrationConnectionById,
87
+ processor: webhookProcessor,
80
88
  }),
81
89
  ],
90
+ webhookProcessors: [{routeIds: ['gitea'] as const, processor: webhookProcessor}],
82
91
  };
83
92
  }
@@ -1,24 +1,24 @@
1
- import {Buffer} from 'node:buffer';
1
+ import {randomUUID} from 'node:crypto';
2
2
  import type {
3
3
  GetIntegrationConnectionByIdFn,
4
4
  PublishSourcePushFn,
5
5
  RecordDeliveryOnlyFn,
6
+ StoredWebhookRequest,
7
+ WebhookProcessingResult,
6
8
  } from '@shipfox/api-integration-core-dto';
7
9
  import {
10
+ createStoredWebhookRequest,
11
+ WEBHOOK_MAX_RAW_BODY_BYTES,
12
+ } from '@shipfox/api-integration-core-dto';
13
+ import {
14
+ ClientError,
8
15
  defineRoute,
9
16
  type RouteGroup,
10
17
  rawBodyPlugin,
11
- verifyHexHmacSignature,
12
18
  WEBHOOK_BODY_LIMIT,
13
19
  } from '@shipfox/node-fastify';
14
- import {logger} from '@shipfox/node-opentelemetry';
15
20
  import type {NodePgDatabase} from 'drizzle-orm/node-postgres';
16
- import {config} from '#config.js';
17
- import {
18
- GiteaWebhookMalformedJsonError,
19
- GiteaWebhookMalformedPushPayloadError,
20
- handleGiteaWebhook,
21
- } from '#core/webhook.js';
21
+ import {createGiteaWebhookProcessor, type GiteaWebhookProcessor} from '#core/webhook-processor.js';
22
22
 
23
23
  const SIGNATURE_HEADER = 'x-gitea-signature';
24
24
  const EVENT_HEADER = 'x-gitea-event';
@@ -29,9 +29,11 @@ export interface CreateGiteaWebhookRoutesOptions {
29
29
  publishSourcePush: PublishSourcePushFn;
30
30
  recordDeliveryOnly: RecordDeliveryOnlyFn;
31
31
  getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
32
+ processor?: GiteaWebhookProcessor | undefined;
32
33
  }
33
34
 
34
35
  export function createGiteaWebhookRoutes(options: CreateGiteaWebhookRoutesOptions): RouteGroup {
36
+ const processor = options.processor ?? createGiteaWebhookProcessor(options);
35
37
  const pushRoute = defineRoute({
36
38
  method: 'POST',
37
39
  path: '/',
@@ -56,49 +58,18 @@ export function createGiteaWebhookRoutes(options: CreateGiteaWebhookRoutesOption
56
58
  return {error: 'missing X-Gitea-Event header'};
57
59
  }
58
60
 
59
- const body = request.body;
60
- if (!Buffer.isBuffer(body)) {
61
+ if (!(request.body instanceof Uint8Array)) {
61
62
  reply.code(400);
62
63
  return {error: 'expected raw JSON body'};
63
64
  }
64
- const rawBody = body.toString('utf8');
65
-
66
- if (!verifyHexHmacSignature({rawBody, signature, secret: config.GITEA_WEBHOOK_SECRET})) {
67
- reply.code(401);
68
- return {error: 'invalid signature'};
69
- }
70
-
71
- try {
72
- await options.coreDb().transaction(async (tx) => {
73
- await handleGiteaWebhook({
74
- tx,
75
- deliveryId,
76
- event,
77
- rawBody,
78
- publishSourcePush: options.publishSourcePush,
79
- recordDeliveryOnly: options.recordDeliveryOnly,
80
- getIntegrationConnectionById: options.getIntegrationConnectionById,
81
- });
82
- });
83
- } catch (error) {
84
- if (error instanceof GiteaWebhookMalformedJsonError) {
85
- logger().warn({deliveryId, err: error}, 'gitea webhook payload JSON parse failed');
86
- reply.code(400);
87
- return {error: 'malformed JSON'};
88
- }
89
- if (error instanceof GiteaWebhookMalformedPushPayloadError) {
90
- logger().warn(
91
- {deliveryId, issues: error.issues},
92
- 'gitea webhook push payload failed schema validation',
93
- );
94
- reply.code(400);
95
- return {error: 'malformed push payload'};
96
- }
97
- throw error;
98
- }
99
-
100
- reply.code(204);
101
- return null;
65
+ const result = await processor.process(
66
+ createGiteaStoredWebhookRequest({
67
+ body: request.body,
68
+ headers: request.headers,
69
+ rawQueryString: request.raw.url?.split('?')[1] ?? '',
70
+ }),
71
+ );
72
+ return sendGiteaWebhookResponse(reply, result);
102
73
  },
103
74
  });
104
75
 
@@ -109,3 +80,56 @@ export function createGiteaWebhookRoutes(options: CreateGiteaWebhookRoutesOption
109
80
  routes: [pushRoute],
110
81
  };
111
82
  }
83
+
84
+ function createGiteaStoredWebhookRequest(input: {
85
+ body: Uint8Array;
86
+ headers: Record<string, string | string[] | undefined>;
87
+ rawQueryString: string;
88
+ }): StoredWebhookRequest {
89
+ if (input.body.byteLength > WEBHOOK_MAX_RAW_BODY_BYTES) {
90
+ throw new ClientError('Webhook request body is too large', 'body-too-large', {status: 413});
91
+ }
92
+ try {
93
+ return createStoredWebhookRequest({
94
+ requestId: randomUUID(),
95
+ routeId: 'gitea',
96
+ receivedAt: new Date().toISOString(),
97
+ rawQueryString: input.rawQueryString,
98
+ headers: giteaWebhookHeaders(input.headers),
99
+ body: input.body,
100
+ });
101
+ } catch (error) {
102
+ throw new ClientError('Webhook request metadata is invalid', 'invalid-webhook-request', {
103
+ cause: error,
104
+ });
105
+ }
106
+ }
107
+
108
+ function giteaWebhookHeaders(headers: Record<string, string | string[] | undefined>) {
109
+ return Object.fromEntries(
110
+ ['content-type', DELIVERY_HEADER, EVENT_HEADER, SIGNATURE_HEADER].flatMap((name) => {
111
+ const value = headers[name];
112
+ return typeof value === 'string' ? [[name, value]] : [];
113
+ }),
114
+ );
115
+ }
116
+
117
+ function sendGiteaWebhookResponse(
118
+ reply: {code(statusCode: number): void},
119
+ result: WebhookProcessingResult,
120
+ ) {
121
+ if (result.outcome === 'discarded' && result.reason === 'invalid_signature') {
122
+ reply.code(401);
123
+ return {error: 'invalid signature'};
124
+ }
125
+ if (result.outcome === 'discarded' && result.reason === 'malformed_payload') {
126
+ reply.code(400);
127
+ return {error: 'malformed JSON'};
128
+ }
129
+ if (result.outcome === 'discarded' && result.reason === 'unsupported_event') {
130
+ reply.code(400);
131
+ return {error: 'malformed push payload'};
132
+ }
133
+ reply.code(204);
134
+ return null;
135
+ }