@antasphere/chassis-server 1.1.0 → 1.3.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 (187) hide show
  1. package/dist/api/apikeys.d.ts +4 -1
  2. package/dist/api/apikeys.d.ts.map +1 -1
  3. package/dist/api/apikeys.js +11 -1
  4. package/dist/api/apikeys.js.map +1 -1
  5. package/dist/api/create-api.d.ts +3 -0
  6. package/dist/api/create-api.d.ts.map +1 -1
  7. package/dist/api/create-api.js +8 -3
  8. package/dist/api/create-api.js.map +1 -1
  9. package/dist/api/invitations.d.ts.map +1 -1
  10. package/dist/api/invitations.js +6 -0
  11. package/dist/api/invitations.js.map +1 -1
  12. package/dist/api/members.d.ts +5 -2
  13. package/dist/api/members.d.ts.map +1 -1
  14. package/dist/api/members.js +1 -0
  15. package/dist/api/members.js.map +1 -1
  16. package/dist/api/projects.d.ts +3 -0
  17. package/dist/api/projects.d.ts.map +1 -1
  18. package/dist/api/projects.js +13 -1
  19. package/dist/api/projects.js.map +1 -1
  20. package/dist/api/webhooks.d.ts +24 -0
  21. package/dist/api/webhooks.d.ts.map +1 -0
  22. package/dist/api/webhooks.js +170 -0
  23. package/dist/api/webhooks.js.map +1 -0
  24. package/dist/app.d.ts.map +1 -1
  25. package/dist/app.js +11 -5
  26. package/dist/app.js.map +1 -1
  27. package/dist/boot.d.ts.map +1 -1
  28. package/dist/boot.js +47 -4
  29. package/dist/boot.js.map +1 -1
  30. package/dist/email/index.d.ts +1 -0
  31. package/dist/email/index.d.ts.map +1 -1
  32. package/dist/email/index.js +1 -0
  33. package/dist/email/index.js.map +1 -1
  34. package/dist/env.d.ts +9 -0
  35. package/dist/env.d.ts.map +1 -1
  36. package/dist/env.js +11 -0
  37. package/dist/env.js.map +1 -1
  38. package/dist/identity/demo-pass-rules.d.ts.map +1 -1
  39. package/dist/identity/demo-pass-rules.js +6 -1
  40. package/dist/identity/demo-pass-rules.js.map +1 -1
  41. package/dist/identity/hub-reconcile.d.ts +2 -2
  42. package/dist/identity/hub-reconcile.d.ts.map +1 -1
  43. package/dist/identity/hub-reconcile.js.map +1 -1
  44. package/dist/index.d.ts +2 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/jobs/index.d.ts +1 -1
  47. package/dist/jobs/index.d.ts.map +1 -1
  48. package/dist/jobs/index.js.map +1 -1
  49. package/dist/jobs/pgboss.d.ts +34 -0
  50. package/dist/jobs/pgboss.d.ts.map +1 -1
  51. package/dist/jobs/pgboss.js +32 -9
  52. package/dist/jobs/pgboss.js.map +1 -1
  53. package/dist/mcp/index.d.ts +1 -0
  54. package/dist/mcp/index.d.ts.map +1 -1
  55. package/dist/mcp/index.js +1 -0
  56. package/dist/mcp/index.js.map +1 -1
  57. package/dist/mcp/server.d.ts +1 -1
  58. package/dist/mcp/server.d.ts.map +1 -1
  59. package/dist/mcp/server.js +11 -2
  60. package/dist/mcp/server.js.map +1 -1
  61. package/dist/mcp/tool-kit.d.ts +2 -0
  62. package/dist/mcp/tool-kit.d.ts.map +1 -1
  63. package/dist/mcp/tool-kit.js +5 -1
  64. package/dist/mcp/tool-kit.js.map +1 -1
  65. package/dist/mcp/webhooks.d.ts +16 -0
  66. package/dist/mcp/webhooks.d.ts.map +1 -0
  67. package/dist/mcp/webhooks.js +226 -0
  68. package/dist/mcp/webhooks.js.map +1 -0
  69. package/dist/members/removal.d.ts +10 -2
  70. package/dist/members/removal.d.ts.map +1 -1
  71. package/dist/members/removal.js +3 -0
  72. package/dist/members/removal.js.map +1 -1
  73. package/dist/middleware/idempotency.d.ts.map +1 -1
  74. package/dist/middleware/idempotency.js +3 -1
  75. package/dist/middleware/idempotency.js.map +1 -1
  76. package/dist/middleware/index.d.ts +1 -0
  77. package/dist/middleware/index.d.ts.map +1 -1
  78. package/dist/middleware/index.js +1 -0
  79. package/dist/middleware/index.js.map +1 -1
  80. package/dist/middleware/scopes.d.ts +9 -1
  81. package/dist/middleware/scopes.d.ts.map +1 -1
  82. package/dist/middleware/scopes.js +8 -1
  83. package/dist/middleware/scopes.js.map +1 -1
  84. package/dist/middleware/static-assets.d.ts +63 -0
  85. package/dist/middleware/static-assets.d.ts.map +1 -0
  86. package/dist/middleware/static-assets.js +125 -0
  87. package/dist/middleware/static-assets.js.map +1 -0
  88. package/dist/secret-box.d.ts +10 -0
  89. package/dist/secret-box.d.ts.map +1 -0
  90. package/dist/secret-box.js +87 -0
  91. package/dist/secret-box.js.map +1 -0
  92. package/dist/testing/helpers.d.ts +8 -0
  93. package/dist/testing/helpers.d.ts.map +1 -1
  94. package/dist/testing/helpers.js.map +1 -1
  95. package/dist/tool-definition.d.ts +24 -0
  96. package/dist/tool-definition.d.ts.map +1 -1
  97. package/dist/tool-definition.js.map +1 -1
  98. package/dist/webhooks/catalog.d.ts +80 -0
  99. package/dist/webhooks/catalog.d.ts.map +1 -0
  100. package/dist/webhooks/catalog.js +134 -0
  101. package/dist/webhooks/catalog.js.map +1 -0
  102. package/dist/webhooks/index.d.ts +9 -0
  103. package/dist/webhooks/index.d.ts.map +1 -0
  104. package/dist/webhooks/index.js +9 -0
  105. package/dist/webhooks/index.js.map +1 -0
  106. package/dist/webhooks/jobs.d.ts +12 -0
  107. package/dist/webhooks/jobs.d.ts.map +1 -0
  108. package/dist/webhooks/jobs.js +77 -0
  109. package/dist/webhooks/jobs.js.map +1 -0
  110. package/dist/webhooks/mail.d.ts +24 -0
  111. package/dist/webhooks/mail.d.ts.map +1 -0
  112. package/dist/webhooks/mail.js +21 -0
  113. package/dist/webhooks/mail.js.map +1 -0
  114. package/dist/webhooks/match.d.ts +22 -0
  115. package/dist/webhooks/match.d.ts.map +1 -0
  116. package/dist/webhooks/match.js +40 -0
  117. package/dist/webhooks/match.js.map +1 -0
  118. package/dist/webhooks/safe-fetch.d.ts +94 -0
  119. package/dist/webhooks/safe-fetch.d.ts.map +1 -0
  120. package/dist/webhooks/safe-fetch.js +263 -0
  121. package/dist/webhooks/safe-fetch.js.map +1 -0
  122. package/dist/webhooks/service.d.ts +245 -0
  123. package/dist/webhooks/service.d.ts.map +1 -0
  124. package/dist/webhooks/service.js +861 -0
  125. package/dist/webhooks/service.js.map +1 -0
  126. package/dist/webhooks/signing.d.ts +40 -0
  127. package/dist/webhooks/signing.d.ts.map +1 -0
  128. package/dist/webhooks/signing.js +108 -0
  129. package/dist/webhooks/signing.js.map +1 -0
  130. package/dist/webhooks/tx-send.d.ts +31 -0
  131. package/dist/webhooks/tx-send.d.ts.map +1 -0
  132. package/dist/webhooks/tx-send.js +22 -0
  133. package/dist/webhooks/tx-send.js.map +1 -0
  134. package/package.json +7 -3
  135. package/src/api/apikeys.ts +12 -1
  136. package/src/api/create-api.ts +17 -3
  137. package/src/api/invitations.ts +6 -0
  138. package/src/api/members.ts +6 -2
  139. package/src/api/projects.ts +16 -1
  140. package/src/api/webhooks.ts +205 -0
  141. package/src/app.ts +17 -4
  142. package/src/boot.ts +48 -4
  143. package/src/email/index.ts +1 -0
  144. package/src/env.ts +11 -0
  145. package/src/identity/demo-pass-rules.ts +6 -1
  146. package/src/identity/hub-reconcile.ts +2 -2
  147. package/src/index.ts +13 -1
  148. package/src/jobs/index.ts +1 -0
  149. package/src/jobs/pgboss.ts +71 -9
  150. package/src/mcp/index.ts +1 -0
  151. package/src/mcp/server.ts +10 -2
  152. package/src/mcp/tool-kit.ts +7 -1
  153. package/src/mcp/webhooks.ts +370 -0
  154. package/src/members/removal.ts +21 -1
  155. package/src/middleware/idempotency.ts +3 -1
  156. package/src/middleware/index.ts +11 -0
  157. package/src/middleware/scopes.ts +14 -0
  158. package/src/middleware/static-assets.ts +144 -0
  159. package/src/secret-box.ts +93 -0
  160. package/src/testing/helpers.ts +8 -0
  161. package/src/tool-definition.ts +29 -0
  162. package/src/webhooks/catalog.ts +205 -0
  163. package/src/webhooks/index.ts +58 -0
  164. package/src/webhooks/jobs.ts +92 -0
  165. package/src/webhooks/mail.ts +48 -0
  166. package/src/webhooks/match.ts +52 -0
  167. package/src/webhooks/safe-fetch.ts +335 -0
  168. package/src/webhooks/service.ts +1089 -0
  169. package/src/webhooks/signing.ts +132 -0
  170. package/src/webhooks/tx-send.ts +53 -0
  171. package/test/host/drizzle/0002_webhooks.sql +49 -0
  172. package/test/host/drizzle/meta/0002_snapshot.json +3952 -0
  173. package/test/host/drizzle/meta/_journal.json +8 -1
  174. package/test/host/identity.ts +6 -1
  175. package/test/host/minimal-host.ts +2 -1
  176. package/test/host/minimal-tool.ts +32 -4
  177. package/test/integration/mcp-webhooks.test.ts +360 -0
  178. package/test/integration/webhooks.test.ts +1684 -0
  179. package/test/unit/secret-box.test.ts +124 -0
  180. package/test/unit/static-assets.test.ts +227 -0
  181. package/test/unit/tx-send.test.ts +23 -0
  182. package/test/unit/webhook-catalog.test.ts +151 -0
  183. package/test/unit/webhook-env.test.ts +60 -0
  184. package/test/unit/webhook-jobs.test.ts +92 -0
  185. package/test/unit/webhook-match.test.ts +81 -0
  186. package/test/unit/webhook-safe-fetch.test.ts +330 -0
  187. package/test/unit/webhook-signing.test.ts +169 -0
@@ -0,0 +1,132 @@
1
+ import { createHmac, randomBytes } from 'node:crypto';
2
+ import { constantTimeEquals } from '../constant-time.js';
3
+
4
+ /**
5
+ * Standard Webhooks signing (https://www.standardwebhooks.com/, the symmetric
6
+ * `v1` scheme): the signed content is `${id}.${timestamp}.${body}`, HMAC-SHA256
7
+ * keyed with the secret's raw bytes (the standard base64 after the `whsec_`
8
+ * prefix), the timestamp in unix SECONDS. A delivery carries three headers:
9
+ * `webhook-id`, `webhook-timestamp`, and `webhook-signature`, a space-separated
10
+ * list of `v1,<standard base64>` entries (one per live secret, so a receiver
11
+ * holding either the old or the new secret verifies during a rotation).
12
+ *
13
+ * The body is signed exactly as it is sent: the caller serialises once and
14
+ * passes the same string here and to the request.
15
+ */
16
+ export const WEBHOOK_SECRET_PREFIX = 'whsec_';
17
+
18
+ /** The spec's bounds on a secret's raw length. */
19
+ const MIN_SECRET_BYTES = 24;
20
+ const MAX_SECRET_BYTES = 64;
21
+ const GENERATED_SECRET_BYTES = 24;
22
+ const DEFAULT_TOLERANCE_SECONDS = 300;
23
+ const STANDARD_BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
24
+
25
+ export class SigningError extends Error {
26
+ readonly code: 'malformed_secret' | 'no_secret';
27
+ constructor(code: 'malformed_secret' | 'no_secret', message: string) {
28
+ super(message);
29
+ this.name = 'SigningError';
30
+ this.code = code;
31
+ }
32
+ }
33
+
34
+ /** A new secret: the prefix + 24 random bytes, standard base64. */
35
+ export function generateWebhookSecret(): string {
36
+ return WEBHOOK_SECRET_PREFIX + randomBytes(GENERATED_SECRET_BYTES).toString('base64');
37
+ }
38
+
39
+ /** The raw key bytes of a secret; throws SigningError('malformed_secret') when the prefix or the base64 is wrong. */
40
+ export function webhookSecretKey(secret: string): Buffer {
41
+ if (typeof secret !== 'string' || !secret.startsWith(WEBHOOK_SECRET_PREFIX)) {
42
+ throw new SigningError('malformed_secret', `a webhook secret starts with ${WEBHOOK_SECRET_PREFIX}`);
43
+ }
44
+ const encoded = secret.slice(WEBHOOK_SECRET_PREFIX.length);
45
+ if (encoded.length % 4 !== 0 || !STANDARD_BASE64.test(encoded)) {
46
+ throw new SigningError('malformed_secret', 'a webhook secret is standard base64 after its prefix');
47
+ }
48
+ const key = Buffer.from(encoded, 'base64');
49
+ // Buffer.from tolerates non-canonical padding bits: only the one spelling passes.
50
+ if (key.toString('base64') !== encoded) {
51
+ throw new SigningError('malformed_secret', 'a webhook secret is canonical standard base64');
52
+ }
53
+ if (key.length < MIN_SECRET_BYTES || key.length > MAX_SECRET_BYTES) {
54
+ throw new SigningError(
55
+ 'malformed_secret',
56
+ `a webhook secret holds ${MIN_SECRET_BYTES} to ${MAX_SECRET_BYTES} bytes`
57
+ );
58
+ }
59
+ return key;
60
+ }
61
+
62
+ function signature(key: Buffer, id: string, timestamp: number | string, body: string): string {
63
+ return createHmac('sha256', key).update(`${id}.${timestamp}.${body}`, 'utf8').digest('base64');
64
+ }
65
+
66
+ function assertTimestamp(timestampSeconds: number): void {
67
+ if (!Number.isSafeInteger(timestampSeconds) || timestampSeconds < 0) {
68
+ throw new RangeError('a webhook timestamp is a whole number of unix seconds');
69
+ }
70
+ }
71
+
72
+ /** The `webhook-signature` header value: one `v1,<b64>` entry per secret, space-separated (two during a rotation overlap). */
73
+ export function signWebhook(
74
+ secrets: readonly string[],
75
+ id: string,
76
+ timestampSeconds: number,
77
+ body: string
78
+ ): string {
79
+ if (secrets.length === 0) {
80
+ throw new SigningError('no_secret', 'a webhook is signed with at least one secret');
81
+ }
82
+ assertTimestamp(timestampSeconds);
83
+ return secrets
84
+ .map((secret) => `v1,${signature(webhookSecretKey(secret), id, timestampSeconds, body)}`)
85
+ .join(' ');
86
+ }
87
+
88
+ /** The three Standard Webhooks headers plus content-type: { 'webhook-id', 'webhook-timestamp', 'webhook-signature', 'content-type': 'application/json' }. */
89
+ export function webhookHeaders(
90
+ secrets: readonly string[],
91
+ id: string,
92
+ timestampSeconds: number,
93
+ body: string
94
+ ): Record<string, string> {
95
+ return {
96
+ 'webhook-id': id,
97
+ 'webhook-timestamp': String(timestampSeconds),
98
+ 'webhook-signature': signWebhook(secrets, id, timestampSeconds, body),
99
+ 'content-type': 'application/json'
100
+ };
101
+ }
102
+
103
+ /**
104
+ * A receiver's check, used by the tests and quoted in the docs: any `v1,` entry of the header verifies
105
+ * under this secret (constant-time compare), and the timestamp is within `toleranceSeconds` (default 300)
106
+ * of `now` (default Date.now()/1000). A malformed secret throws (a configuration error); anything wrong
107
+ * with the headers or the body answers false.
108
+ */
109
+ export function verifyWebhookSignature(
110
+ secret: string,
111
+ headers: { id: string; timestamp: string; signature: string },
112
+ body: string,
113
+ opts: { nowSeconds?: number; toleranceSeconds?: number } = {}
114
+ ): boolean {
115
+ const key = webhookSecretKey(secret);
116
+ if (!/^\d{1,15}$/.test(headers.timestamp)) return false;
117
+ const timestamp = Number(headers.timestamp);
118
+ const now = opts.nowSeconds ?? Date.now() / 1000;
119
+ const tolerance = opts.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
120
+ if (!(Math.abs(now - timestamp) <= tolerance)) return false;
121
+
122
+ // The receiver signs the header's own text, as the sender did.
123
+ const expected = signature(key, headers.id, headers.timestamp, body);
124
+ let verified = false;
125
+ for (const entry of headers.signature.split(' ')) {
126
+ const comma = entry.indexOf(',');
127
+ if (comma === -1 || entry.slice(0, comma) !== 'v1') continue;
128
+ // Every entry is compared, so the answer's timing does not tell which one matched.
129
+ if (constantTimeEquals(entry.slice(comma + 1), expected)) verified = true;
130
+ }
131
+ return verified;
132
+ }
@@ -0,0 +1,53 @@
1
+ import type PgBoss from 'pg-boss';
2
+ import type { DbConn } from '@antasphere/chassis-db';
3
+
4
+ /**
5
+ * A pg-boss send INSIDE a drizzle transaction (or on a plain handle): the job
6
+ * row is written by the same connection as the caller's own rows, so it
7
+ * commits with them and never exists for a rolled-back write. pg-boss takes
8
+ * an executor (`db.executeSql(text, values)`), and the executor here is the
9
+ * connection drizzle's handle wraps: a `pg` PoolClient inside a transaction,
10
+ * the Pool outside one. The statement runs exactly as pg-boss wrote it, its
11
+ * `$n` placeholders untouched: a placeholder pg-boss reuses three times with
12
+ * three casts (`right($5,1)`, `CAST($5 as timestamptz)`, `COALESCE($5,'0')`)
13
+ * keeps its one type, where a rebinding into drizzle's own parameters gave
14
+ * each occurrence a type of its own and refused a `startAfter` in seconds
15
+ * (the chassis suite, scenario 8).
16
+ *
17
+ * The connection is reached through drizzle's node-postgres session
18
+ * (`session.client`), an internal of the driver binding; `tx-send.test.ts`
19
+ * pins the shape against the installed version. A drizzle bump re-verifies it.
20
+ */
21
+ interface QueryClient {
22
+ query(text: string, values: unknown[]): Promise<{ rows: unknown[] }>;
23
+ }
24
+
25
+ /** The pg client (or pool) behind a drizzle handle or transaction. */
26
+ export function connectionOf(conn: DbConn): QueryClient {
27
+ const client = (conn as unknown as { session?: { client?: Partial<QueryClient> } }).session?.client;
28
+ if (!client || typeof client.query !== 'function') {
29
+ throw new Error('tx-send: the drizzle handle exposes no node-postgres client (session.client)');
30
+ }
31
+ return client as QueryClient;
32
+ }
33
+
34
+ export function drizzleExecutor(conn: DbConn): PgBoss.Db {
35
+ const client = connectionOf(conn);
36
+ return {
37
+ executeSql: async (text: string, values: unknown[]) => {
38
+ const res = await client.query(text, values);
39
+ return { rows: res.rows as Record<string, unknown>[] };
40
+ }
41
+ };
42
+ }
43
+
44
+ /** Send one job through `conn`; answers the job id (null when the queue does not exist). */
45
+ export function sendInTx(
46
+ boss: PgBoss,
47
+ conn: DbConn,
48
+ queue: string,
49
+ data: object,
50
+ options: PgBoss.SendOptions = {}
51
+ ): Promise<string | null> {
52
+ return boss.send(queue, data, { ...options, db: drizzleExecutor(conn) });
53
+ }
@@ -0,0 +1,49 @@
1
+ CREATE TABLE "webhook_deliveries" (
2
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3
+ "endpoint_id" uuid NOT NULL,
4
+ "workspace_id" uuid NOT NULL,
5
+ "event_id" uuid NOT NULL,
6
+ "event_type" text NOT NULL,
7
+ "payload" jsonb NOT NULL,
8
+ "status" text DEFAULT 'pending' NOT NULL,
9
+ "attempt" integer DEFAULT 0 NOT NULL,
10
+ "last_status_code" integer,
11
+ "last_error" text,
12
+ "next_attempt_at" timestamp with time zone,
13
+ "delivered_at" timestamp with time zone,
14
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
15
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
16
+ CONSTRAINT "webhook_deliveries_status_check" CHECK ("webhook_deliveries"."status" IN ('pending', 'delivered', 'failed', 'held'))
17
+ );
18
+ --> statement-breakpoint
19
+ CREATE TABLE "webhook_endpoints" (
20
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
21
+ "workspace_id" uuid NOT NULL,
22
+ "url" text NOT NULL,
23
+ "description" text DEFAULT '' NOT NULL,
24
+ "events" text[] NOT NULL,
25
+ "filters" jsonb DEFAULT '{}'::jsonb NOT NULL,
26
+ "enabled" boolean DEFAULT true NOT NULL,
27
+ "secret" text NOT NULL,
28
+ "previous_secret" text,
29
+ "previous_secret_expires_at" timestamp with time zone,
30
+ "created_by" text,
31
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
32
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
33
+ "disabled_at" timestamp with time zone,
34
+ "disabled_reason" text,
35
+ "consecutive_failures" integer DEFAULT 0 NOT NULL,
36
+ "first_failure_at" timestamp with time zone,
37
+ "last_delivery_at" timestamp with time zone,
38
+ "last_delivery_status" text,
39
+ CONSTRAINT "webhook_endpoints_last_status_check" CHECK ("webhook_endpoints"."last_delivery_status" IS NULL OR "webhook_endpoints"."last_delivery_status" IN ('delivered', 'failed'))
40
+ );
41
+ --> statement-breakpoint
42
+ ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_endpoint_id_webhook_endpoints_id_fk" FOREIGN KEY ("endpoint_id") REFERENCES "public"."webhook_endpoints"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
43
+ ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
44
+ ALTER TABLE "webhook_endpoints" ADD CONSTRAINT "webhook_endpoints_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
45
+ ALTER TABLE "webhook_endpoints" ADD CONSTRAINT "webhook_endpoints_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
46
+ CREATE INDEX "webhook_deliveries_endpoint_created_id_idx" ON "webhook_deliveries" USING btree ("endpoint_id","created_at","id");--> statement-breakpoint
47
+ CREATE INDEX "webhook_deliveries_created_idx" ON "webhook_deliveries" USING btree ("created_at");--> statement-breakpoint
48
+ CREATE INDEX "webhook_endpoints_workspace_idx" ON "webhook_endpoints" USING btree ("workspace_id");--> statement-breakpoint
49
+ CREATE INDEX "webhook_endpoints_workspace_created_id_idx" ON "webhook_endpoints" USING btree ("workspace_id","created_at","id");