@antasphere/chassis-contract 1.0.2 → 1.2.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/dist/identity.d.ts +7 -1
- package/dist/identity.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/routes/index.d.ts +1118 -0
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +184 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/schemas/cli-package.d.ts +21 -0
- package/dist/schemas/cli-package.d.ts.map +1 -0
- package/dist/schemas/cli-package.js +21 -0
- package/dist/schemas/cli-package.js.map +1 -0
- package/dist/schemas/instance.d.ts +4 -0
- package/dist/schemas/instance.d.ts.map +1 -1
- package/dist/schemas/instance.js +8 -0
- package/dist/schemas/instance.js.map +1 -1
- package/dist/schemas/webhooks.d.ts +281 -0
- package/dist/schemas/webhooks.d.ts.map +1 -0
- package/dist/schemas/webhooks.js +175 -0
- package/dist/schemas/webhooks.js.map +1 -0
- package/package.json +1 -1
- package/src/identity.ts +7 -2
- package/src/index.ts +2 -0
- package/src/routes/index.ts +233 -0
- package/src/schemas/cli-package.ts +22 -0
- package/src/schemas/instance.ts +8 -0
- package/src/schemas/webhooks.ts +226 -0
- package/test/webhooks.test.ts +141 -0
package/src/routes/index.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
} from '../schemas/cli-auth.js';
|
|
39
39
|
import { ssoCliConnectSchema } from '../schemas/sso-connect.js';
|
|
40
40
|
import { ssoLogoutResponseSchema } from '../schemas/sso-logout.js';
|
|
41
|
+
import { cliPackageRequestSchema } from '../schemas/cli-package.js';
|
|
41
42
|
import {
|
|
42
43
|
breakGlassClaimOwnershipRequestSchema,
|
|
43
44
|
breakGlassClaimOwnershipSchema,
|
|
@@ -77,6 +78,22 @@ import {
|
|
|
77
78
|
teamsListSchema,
|
|
78
79
|
teamUpdateSchema
|
|
79
80
|
} from '../schemas/teams.js';
|
|
81
|
+
import {
|
|
82
|
+
webhookCatalogSchema,
|
|
83
|
+
webhookDeliveriesListQuerySchema,
|
|
84
|
+
webhookDeliveriesListSchema,
|
|
85
|
+
webhookDeliveryParamsSchema,
|
|
86
|
+
webhookDeliverySchema,
|
|
87
|
+
webhookEndpointCreatedSchema,
|
|
88
|
+
webhookEndpointCreateSchema,
|
|
89
|
+
webhookEndpointSchema,
|
|
90
|
+
webhookEndpointUpdateSchema,
|
|
91
|
+
webhookParamsSchema,
|
|
92
|
+
webhookSecretRotatedSchema,
|
|
93
|
+
webhooksListQuerySchema,
|
|
94
|
+
webhooksListSchema,
|
|
95
|
+
webhookTestResultSchema
|
|
96
|
+
} from '../schemas/webhooks.js';
|
|
80
97
|
|
|
81
98
|
/**
|
|
82
99
|
* Server-only entry: route contracts for @hono/zod-openapi. Importing this
|
|
@@ -135,6 +152,55 @@ export const instanceRoute = createRoute({
|
|
|
135
152
|
}
|
|
136
153
|
});
|
|
137
154
|
|
|
155
|
+
// ── The tool's own CLI package (cloud edition only, PRDCT-3312) ─────────────
|
|
156
|
+
// Reached with NO credential but the hub exchange token in the body (the
|
|
157
|
+
// `/sso/cli-connect` token, burned in the same one-time ledger). NOT in
|
|
158
|
+
// PUBLIC_API_PATHS and unlisted in the machine scope allowlist on purpose: a
|
|
159
|
+
// request without a credential reaches the handler, a key or an OAuth bearer
|
|
160
|
+
// meets 403 endpoint_not_allowed before it. The route exists ONLY on
|
|
161
|
+
// EDITION=cloud; oss answers the plain 404 terminator. Rate-limited on the
|
|
162
|
+
// CLI-connect wall, per client address.
|
|
163
|
+
export const cliPackageRoute = createRoute({
|
|
164
|
+
method: 'post',
|
|
165
|
+
path: '/cli/package',
|
|
166
|
+
tags: ['cli'],
|
|
167
|
+
summary: "Download the tool's own CLI package (cloud edition) on a hub-minted exchange token",
|
|
168
|
+
description:
|
|
169
|
+
'Answers the gzipped tarball the release packed (the output of `pnpm pack` of the tool CLI), named ' +
|
|
170
|
+
"as npm names it, to a member the hub lets into the tool. The client first asks the hub's " +
|
|
171
|
+
'`POST /api/v1/sso/tool-token` for an exchange token for this instance, then posts it here as ' +
|
|
172
|
+
'`{ token }`. The refusals a client meets, and what each one means: ' +
|
|
173
|
+
"the hub's 403 `tool_access_denied` on the tool-token step (this person has no access to this tool " +
|
|
174
|
+
'on the hub); 401 `invalid_token` here (bad signature, issuer, audience or purpose, expired, or ' +
|
|
175
|
+
'already used: run the exchange again, each token works once, here or at `/sso/cli-connect`); ' +
|
|
176
|
+
'404 `cli_not_packaged` (the instance was released without its CLI package); a plain 404 ' +
|
|
177
|
+
'`not_found` (a self-hosted instance, or a chassis from before this route: the path is not ' +
|
|
178
|
+
'registered); 403 `endpoint_not_allowed` (an API key or an OAuth bearer was sent: this route takes ' +
|
|
179
|
+
'the exchange token in its body and never a machine credential).',
|
|
180
|
+
request: { body: jsonRequestBody(cliPackageRequestSchema, 'The hub exchange token') },
|
|
181
|
+
responses: {
|
|
182
|
+
200: {
|
|
183
|
+
description: 'The CLI package, a gzipped tarball (application/gzip)',
|
|
184
|
+
content: { 'application/gzip': { schema: { type: 'string', format: 'binary' } } }
|
|
185
|
+
},
|
|
186
|
+
400: jsonBody(apiErrorSchema, 'Validation error'),
|
|
187
|
+
401: jsonBody(
|
|
188
|
+
apiErrorSchema,
|
|
189
|
+
'invalid_token: bad signature/iss/aud/purpose, expired, or an already used jti (here or at /sso/cli-connect)'
|
|
190
|
+
),
|
|
191
|
+
403: jsonBody(
|
|
192
|
+
apiErrorSchema,
|
|
193
|
+
'endpoint_not_allowed: a machine credential (API key or OAuth bearer) was sent'
|
|
194
|
+
),
|
|
195
|
+
404: jsonBody(
|
|
196
|
+
apiErrorSchema,
|
|
197
|
+
'cli_not_packaged: the instance was released without its CLI package (a plain not_found: not a cloud instance, or a chassis without the route)'
|
|
198
|
+
),
|
|
199
|
+
429: errorResponses[429],
|
|
200
|
+
500: errorResponses[500]
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
138
204
|
export const setupRoute = createRoute({
|
|
139
205
|
method: 'post',
|
|
140
206
|
path: '/setup',
|
|
@@ -630,6 +696,173 @@ export const teamMemberRemoveRoute = createRoute({
|
|
|
630
696
|
responses: { 200: jsonBody(teamMemberSchema, 'The removed team member'), ...teamErrors }
|
|
631
697
|
});
|
|
632
698
|
|
|
699
|
+
// ── Webhooks ─────────────────────────────────────────────────────────────────
|
|
700
|
+
// Outbound webhooks (PRDCT-3482): URLs a workspace registers to be told, by a
|
|
701
|
+
// signed POST, when one of the tool's events happens. Owners and admins only,
|
|
702
|
+
// on both editions; a guest gets 403 `guest_forbidden`, a session a demo pass
|
|
703
|
+
// opened 403 `demo_session`. The signing secret appears in two answers only,
|
|
704
|
+
// the create and the rotation, and never again.
|
|
705
|
+
|
|
706
|
+
/** The refusals every webhook route shares (the writes add their own). */
|
|
707
|
+
const webhookErrors = {
|
|
708
|
+
401: errorResponses[401],
|
|
709
|
+
403: jsonBody(apiErrorSchema, 'guest_forbidden, forbidden (not an owner or admin), or demo_session'),
|
|
710
|
+
404: errorResponses[404]
|
|
711
|
+
};
|
|
712
|
+
const urlRefused400 = jsonBody(
|
|
713
|
+
apiErrorSchema,
|
|
714
|
+
'Validation error, url_refused (the address is not one this instance may deliver to: a private network, a port, the scheme), unknown_event (a type or family the catalog does not declare) or unknown_subject (a filter key the catalog does not declare)'
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
export const webhooksListRoute = createRoute({
|
|
718
|
+
method: 'get',
|
|
719
|
+
path: '/webhooks',
|
|
720
|
+
tags: ['webhooks'],
|
|
721
|
+
summary: "List the workspace's webhook endpoints (owner or admin; cursor-paginated)",
|
|
722
|
+
request: { query: webhooksListQuerySchema },
|
|
723
|
+
responses: {
|
|
724
|
+
200: jsonBody(webhooksListSchema, 'Webhook endpoints, newest first'),
|
|
725
|
+
400: errorResponses[400],
|
|
726
|
+
401: errorResponses[401],
|
|
727
|
+
403: webhookErrors[403]
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
export const webhookCatalogRoute = createRoute({
|
|
732
|
+
method: 'get',
|
|
733
|
+
path: '/webhooks/events',
|
|
734
|
+
tags: ['webhooks'],
|
|
735
|
+
summary:
|
|
736
|
+
'The event catalog: every event type this instance can deliver, its subjects and the JSON Schema of its data',
|
|
737
|
+
responses: {
|
|
738
|
+
200: jsonBody(webhookCatalogSchema, 'The subjects and the event types'),
|
|
739
|
+
401: errorResponses[401],
|
|
740
|
+
403: jsonBody(apiErrorSchema, 'guest_forbidden (every other member reads the catalog)')
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
export const webhookCreateRoute = createRoute({
|
|
745
|
+
method: 'post',
|
|
746
|
+
path: '/webhooks',
|
|
747
|
+
tags: ['webhooks'],
|
|
748
|
+
summary: 'Register a webhook endpoint (owner or admin); its signing secret is shown once, here',
|
|
749
|
+
description:
|
|
750
|
+
'An endpoint subscribes to `events`: exact types (`thing.created`), families (`thing.*`) or everything (`*`), ' +
|
|
751
|
+
'checked against the catalog (`GET /webhooks/events`). `filters` narrows the subscription by subject: a ' +
|
|
752
|
+
'subject key mapped to the ids it accepts, AND across keys, OR within a key; an event that does not carry a ' +
|
|
753
|
+
"filtered subject does not match. Every delivery is signed with the endpoint's secret (`whsec_…`), returned " +
|
|
754
|
+
'in this answer only: store it now, it is never shown again (`POST /webhooks/{id}/rotate-secret` makes a new one).',
|
|
755
|
+
request: {
|
|
756
|
+
body: jsonRequestBody(webhookEndpointCreateSchema, 'The URL, what it subscribes to, its filters')
|
|
757
|
+
},
|
|
758
|
+
responses: {
|
|
759
|
+
201: jsonBody(webhookEndpointCreatedSchema, 'The created endpoint, with its signing secret (shown once)'),
|
|
760
|
+
400: urlRefused400,
|
|
761
|
+
...webhookErrors,
|
|
762
|
+
403: jsonBody(
|
|
763
|
+
apiErrorSchema,
|
|
764
|
+
'guest_forbidden, forbidden (not an owner or admin), demo_session, or plan_required'
|
|
765
|
+
)
|
|
766
|
+
}
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
export const webhookGetRoute = createRoute({
|
|
770
|
+
method: 'get',
|
|
771
|
+
path: '/webhooks/{id}',
|
|
772
|
+
tags: ['webhooks'],
|
|
773
|
+
summary: 'Read one webhook endpoint (owner or admin)',
|
|
774
|
+
request: { params: webhookParamsSchema },
|
|
775
|
+
responses: { 200: jsonBody(webhookEndpointSchema, 'The endpoint'), ...webhookErrors }
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
export const webhookUpdateRoute = createRoute({
|
|
779
|
+
method: 'patch',
|
|
780
|
+
path: '/webhooks/{id}',
|
|
781
|
+
tags: ['webhooks'],
|
|
782
|
+
summary: "Change an endpoint's URL, description, events, filters or enabled switch (owner or admin)",
|
|
783
|
+
description: 'Only the fields named change; at least one is required.',
|
|
784
|
+
request: {
|
|
785
|
+
params: webhookParamsSchema,
|
|
786
|
+
body: jsonRequestBody(webhookEndpointUpdateSchema, 'Fields to change')
|
|
787
|
+
},
|
|
788
|
+
responses: {
|
|
789
|
+
200: jsonBody(webhookEndpointSchema, 'The endpoint as it now is'),
|
|
790
|
+
400: urlRefused400,
|
|
791
|
+
...webhookErrors
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
export const webhookDeleteRoute = createRoute({
|
|
796
|
+
method: 'delete',
|
|
797
|
+
path: '/webhooks/{id}',
|
|
798
|
+
tags: ['webhooks'],
|
|
799
|
+
summary: 'Delete a webhook endpoint and its delivery history (owner or admin)',
|
|
800
|
+
request: { params: webhookParamsSchema },
|
|
801
|
+
responses: {
|
|
802
|
+
200: jsonBody(webhookEndpointSchema, 'The deleted endpoint (final snapshot)'),
|
|
803
|
+
...webhookErrors
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
export const webhookRotateSecretRoute = createRoute({
|
|
808
|
+
method: 'post',
|
|
809
|
+
path: '/webhooks/{id}/rotate-secret',
|
|
810
|
+
tags: ['webhooks'],
|
|
811
|
+
summary: "Replace an endpoint's signing secret (owner or admin); the new one is shown once, here",
|
|
812
|
+
description:
|
|
813
|
+
'The previous secret keeps signing until `previousSecretExpiresAt`, so a receiver can switch keys without a gap.',
|
|
814
|
+
request: { params: webhookParamsSchema },
|
|
815
|
+
responses: {
|
|
816
|
+
200: jsonBody(webhookSecretRotatedSchema, 'The endpoint, with its new signing secret (shown once)'),
|
|
817
|
+
...webhookErrors
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
export const webhookTestRoute = createRoute({
|
|
822
|
+
method: 'post',
|
|
823
|
+
path: '/webhooks/{id}/test',
|
|
824
|
+
tags: ['webhooks'],
|
|
825
|
+
summary: 'Send a signed webhook.ping to the endpoint now and report the outcome',
|
|
826
|
+
request: { params: webhookParamsSchema },
|
|
827
|
+
responses: {
|
|
828
|
+
200: jsonBody(webhookTestResultSchema, 'The ping, attempted inline: its outcome is on the delivery'),
|
|
829
|
+
...webhookErrors
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
export const webhookDeliveriesListRoute = createRoute({
|
|
834
|
+
method: 'get',
|
|
835
|
+
path: '/webhooks/{id}/deliveries',
|
|
836
|
+
tags: ['webhooks'],
|
|
837
|
+
summary: "List an endpoint's deliveries, optionally by status (owner or admin; cursor-paginated)",
|
|
838
|
+
request: { params: webhookParamsSchema, query: webhookDeliveriesListQuerySchema },
|
|
839
|
+
responses: {
|
|
840
|
+
200: jsonBody(webhookDeliveriesListSchema, 'Deliveries, newest first'),
|
|
841
|
+
400: errorResponses[400],
|
|
842
|
+
...webhookErrors
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
export const webhookDeliveryGetRoute = createRoute({
|
|
847
|
+
method: 'get',
|
|
848
|
+
path: '/webhooks/{id}/deliveries/{deliveryId}',
|
|
849
|
+
tags: ['webhooks'],
|
|
850
|
+
summary: 'Read one delivery, with the payload as sent and its latest attempt (owner or admin)',
|
|
851
|
+
request: { params: webhookDeliveryParamsSchema },
|
|
852
|
+
responses: { 200: jsonBody(webhookDeliverySchema, 'The delivery'), ...webhookErrors }
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
export const webhookRedeliverRoute = createRoute({
|
|
856
|
+
method: 'post',
|
|
857
|
+
path: '/webhooks/{id}/deliveries/{deliveryId}/redeliver',
|
|
858
|
+
tags: ['webhooks'],
|
|
859
|
+
summary: 'Queue the same event again for this endpoint, as a new delivery',
|
|
860
|
+
description:
|
|
861
|
+
"The new delivery carries the same `eventId`, the receiver's dedupe key, and the same payload.",
|
|
862
|
+
request: { params: webhookDeliveryParamsSchema },
|
|
863
|
+
responses: { 201: jsonBody(webhookDeliverySchema, 'The new delivery, queued'), ...webhookErrors }
|
|
864
|
+
});
|
|
865
|
+
|
|
633
866
|
// ── Demo passes ──────────────────────────────────────────────────────────────
|
|
634
867
|
// A link an owner mints so that one member is signed in without a password,
|
|
635
868
|
// for a demonstration (the demo pass spec). The three routes are registered
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The tool's own CLI package (cloud edition only, PRDCT-3312):
|
|
5
|
+
*
|
|
6
|
+
* POST /cli/package — download the CLI package the release packed, on a
|
|
7
|
+
* hub-minted 120 s exchange JWT
|
|
8
|
+
*
|
|
9
|
+
* The same token the hub's `POST /sso/tool-token` mints for
|
|
10
|
+
* `/sso/cli-connect` (`aud` = THIS instance's resource URL,
|
|
11
|
+
* `purpose: 'sso-connect'`, unique `jti`), consumed one-time-use in the same
|
|
12
|
+
* ledger: a token spent here mints no key at `/sso/cli-connect`, and the
|
|
13
|
+
* other way round. The hub mints it only for a member it lets into the
|
|
14
|
+
* tool, which is what this route rests on. The token rides in the BODY and
|
|
15
|
+
* never in `Authorization`: a machine credential on the path meets the
|
|
16
|
+
* scope gate's 403 `endpoint_not_allowed`.
|
|
17
|
+
*/
|
|
18
|
+
export const cliPackageRequestSchema = z.object({
|
|
19
|
+
/** The hub-minted exchange JWT (compact JWS — three base64url segments). */
|
|
20
|
+
token: z.string().min(1)
|
|
21
|
+
});
|
|
22
|
+
export type CliPackageRequest = z.infer<typeof cliPackageRequestSchema>;
|
package/src/schemas/instance.ts
CHANGED
|
@@ -58,6 +58,14 @@ export const instanceInfoSchema = z.object({
|
|
|
58
58
|
* false, so the self-hosted discovery bytes are unchanged.
|
|
59
59
|
*/
|
|
60
60
|
restricted: z.boolean().optional(),
|
|
61
|
+
/**
|
|
62
|
+
* The tool's own CLI package (cloud edition, PRDCT-3312): present only when
|
|
63
|
+
* the instance was released with it (`CLI_PACKAGE_DIR`), naming the npm
|
|
64
|
+
* package `POST /cli/package` serves and its version. The `team` command
|
|
65
|
+
* reads it to know what it installs and whether to update. ABSENT
|
|
66
|
+
* otherwise, so older discovery bytes are unchanged.
|
|
67
|
+
*/
|
|
68
|
+
cli: z.object({ name: z.string(), version: z.string() }).optional(),
|
|
61
69
|
/**
|
|
62
70
|
* What this version of the tool declares for the billing rail (its priced
|
|
63
71
|
* actions with their default credits, its limits and features per tier):
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { cursorPageQuerySchema, hasControlChars, plainText } from './common.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Outbound webhooks (PRDCT-3482): a URL a workspace registers to be told, by a
|
|
6
|
+
* signed POST, when something happens to the tool's own subjects. The tool
|
|
7
|
+
* declares the event types and the subjects they carry (the catalog); the
|
|
8
|
+
* chassis stores the endpoints, matches the events against them, signs and
|
|
9
|
+
* delivers, and keeps each delivery's history. Owners and admins manage them.
|
|
10
|
+
*
|
|
11
|
+
* The signing secret is NEVER part of an endpoint's shape: it is shown once,
|
|
12
|
+
* at creation and at each rotation, and stored sealed.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What an endpoint subscribes to: an exact event type (`thing.created`), a
|
|
17
|
+
* family (`thing.*`) or everything (`*`).
|
|
18
|
+
*/
|
|
19
|
+
export const webhookEventPatternSchema = z
|
|
20
|
+
.string()
|
|
21
|
+
.max(80)
|
|
22
|
+
.regex(
|
|
23
|
+
/^(\*|[a-z][a-z0-9_]*\.(\*|[a-z][a-z0-9_]*))$/,
|
|
24
|
+
'an event type (thing.created), a family (thing.*) or everything (*)'
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
/** A subject key of the catalog (`thing`, `project`): lowercase, digits and underscores. */
|
|
28
|
+
export const webhookSubjectKeySchema = z
|
|
29
|
+
.string()
|
|
30
|
+
.max(40)
|
|
31
|
+
.regex(/^[a-z][a-z0-9_]*$/, 'lowercase letters, digits and underscores, starting with a letter');
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Narrows a subscription by subject: a subject key mapped to the ids it
|
|
35
|
+
* accepts. AND across keys, OR within a key; an event that does not carry a
|
|
36
|
+
* filtered subject does not match. An empty object filters nothing.
|
|
37
|
+
*/
|
|
38
|
+
export const webhookFiltersSchema = z
|
|
39
|
+
.record(webhookSubjectKeySchema, z.array(plainText(1, 200)).min(1).max(100))
|
|
40
|
+
.refine((v) => Object.keys(v).length <= 10, { error: 'at most 10 subject keys' })
|
|
41
|
+
.describe(
|
|
42
|
+
'Subject key → ids; AND across keys, OR within a key; an event that does not carry a filtered subject does not match'
|
|
43
|
+
);
|
|
44
|
+
export type WebhookFilters = z.infer<typeof webhookFiltersSchema>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The WHATWG URL parser, present in every runtime this contract runs in (Node,
|
|
48
|
+
* the browser); typed here because the package compiles without DOM or Node
|
|
49
|
+
* types, to stay runtime-neutral.
|
|
50
|
+
*/
|
|
51
|
+
type UrlParts = { protocol: string; username: string; password: string };
|
|
52
|
+
const UrlParser = (globalThis as unknown as { URL: new (input: string) => UrlParts }).URL;
|
|
53
|
+
|
|
54
|
+
function isHttpUrlWithoutCredentials(value: string): boolean {
|
|
55
|
+
let parsed: UrlParts;
|
|
56
|
+
try {
|
|
57
|
+
parsed = new UrlParser(value);
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
return (
|
|
62
|
+
(parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
|
|
63
|
+
parsed.username === '' &&
|
|
64
|
+
parsed.password === ''
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Where the deliveries go: an http or https URL without credentials. Whitespace
|
|
70
|
+
* and control characters are refused outright, because the URL parser silently
|
|
71
|
+
* strips tabs and newlines, so the stored string would not be the address
|
|
72
|
+
* delivered to. The address rules (ports, private networks, https on the cloud
|
|
73
|
+
* edition) are the server's, answered `url_refused`.
|
|
74
|
+
*/
|
|
75
|
+
export const webhookUrlSchema = z
|
|
76
|
+
.string()
|
|
77
|
+
.max(2048)
|
|
78
|
+
.refine((v) => !/\s/.test(v) && !hasControlChars(v), {
|
|
79
|
+
error: 'must not contain whitespace or control characters',
|
|
80
|
+
abort: true
|
|
81
|
+
})
|
|
82
|
+
.refine(isHttpUrlWithoutCredentials, { error: 'an http or https URL without credentials' });
|
|
83
|
+
|
|
84
|
+
export const webhookDeliveryStatusSchema = z.enum(['pending', 'delivered', 'failed', 'held']);
|
|
85
|
+
export type WebhookDeliveryStatus = z.infer<typeof webhookDeliveryStatusSchema>;
|
|
86
|
+
|
|
87
|
+
export const webhookEndpointSchema = z.object({
|
|
88
|
+
id: z.string(),
|
|
89
|
+
url: z.string(),
|
|
90
|
+
description: z.string(),
|
|
91
|
+
events: z.array(z.string()),
|
|
92
|
+
filters: z.record(z.string(), z.array(z.string())),
|
|
93
|
+
enabled: z.boolean(),
|
|
94
|
+
createdBy: z.string().nullable(),
|
|
95
|
+
createdAt: z.string(),
|
|
96
|
+
updatedAt: z.string(),
|
|
97
|
+
/** Set with `disabledReason` when the endpoint was switched off after failing too long. */
|
|
98
|
+
disabledAt: z.string().nullable(),
|
|
99
|
+
disabledReason: z.string().nullable(),
|
|
100
|
+
consecutiveFailures: z.number().int(),
|
|
101
|
+
lastDeliveryAt: z.string().nullable(),
|
|
102
|
+
lastDeliveryStatus: z.enum(['delivered', 'failed']).nullable(),
|
|
103
|
+
/** During a rotation, the previous secret keeps signing until this date. */
|
|
104
|
+
previousSecretExpiresAt: z.string().nullable()
|
|
105
|
+
});
|
|
106
|
+
export type WebhookEndpoint = z.infer<typeof webhookEndpointSchema>;
|
|
107
|
+
|
|
108
|
+
/** The endpoint as created, with its signing secret (`whsec_…`), shown ONCE. */
|
|
109
|
+
export const webhookEndpointCreatedSchema = webhookEndpointSchema.extend({
|
|
110
|
+
secret: z.string()
|
|
111
|
+
});
|
|
112
|
+
export type WebhookEndpointCreated = z.infer<typeof webhookEndpointCreatedSchema>;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The endpoint after a rotation, with its new secret, shown ONCE; the previous
|
|
116
|
+
* secret keeps signing until `previousSecretExpiresAt`.
|
|
117
|
+
*/
|
|
118
|
+
export const webhookSecretRotatedSchema = webhookEndpointSchema.extend({
|
|
119
|
+
secret: z.string(),
|
|
120
|
+
previousSecretExpiresAt: z.string()
|
|
121
|
+
});
|
|
122
|
+
export type WebhookSecretRotated = z.infer<typeof webhookSecretRotatedSchema>;
|
|
123
|
+
|
|
124
|
+
const webhookDescriptionSchema = plainText(0, 200);
|
|
125
|
+
const webhookEventsSchema = z.array(webhookEventPatternSchema).min(1).max(50);
|
|
126
|
+
|
|
127
|
+
export const webhookEndpointCreateSchema = z.object({
|
|
128
|
+
url: webhookUrlSchema,
|
|
129
|
+
description: webhookDescriptionSchema.default(''),
|
|
130
|
+
events: webhookEventsSchema,
|
|
131
|
+
filters: webhookFiltersSchema.default({}),
|
|
132
|
+
enabled: z.boolean().default(true)
|
|
133
|
+
});
|
|
134
|
+
/** The INPUT shape: `description`, `filters` and `enabled` may be left out. */
|
|
135
|
+
export type WebhookEndpointCreate = z.input<typeof webhookEndpointCreateSchema>;
|
|
136
|
+
|
|
137
|
+
/** Every field optional, none defaulted (a PATCH never resets what it does not name). */
|
|
138
|
+
export const webhookEndpointUpdateSchema = z
|
|
139
|
+
.object({
|
|
140
|
+
url: webhookUrlSchema.optional(),
|
|
141
|
+
description: webhookDescriptionSchema.optional(),
|
|
142
|
+
events: webhookEventsSchema.optional(),
|
|
143
|
+
filters: webhookFiltersSchema.optional(),
|
|
144
|
+
enabled: z.boolean().optional()
|
|
145
|
+
})
|
|
146
|
+
.refine((b) => Object.values(b).some((v) => v !== undefined), { error: 'nothing to change' });
|
|
147
|
+
export type WebhookEndpointUpdate = z.infer<typeof webhookEndpointUpdateSchema>;
|
|
148
|
+
|
|
149
|
+
export const webhooksListSchema = z.object({
|
|
150
|
+
webhooks: z.array(webhookEndpointSchema),
|
|
151
|
+
nextCursor: z.string().nullable()
|
|
152
|
+
});
|
|
153
|
+
export type WebhooksList = z.infer<typeof webhooksListSchema>;
|
|
154
|
+
|
|
155
|
+
export const webhooksListQuerySchema = cursorPageQuerySchema;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* One event sent to one endpoint, with its attempts. `eventId` is the
|
|
159
|
+
* receiver's dedupe key, the same on every attempt and on a redelivery;
|
|
160
|
+
* `lastStatusCode` and `lastError` describe the latest attempt only;
|
|
161
|
+
* `payload` is the envelope as sent.
|
|
162
|
+
*/
|
|
163
|
+
export const webhookDeliverySchema = z.object({
|
|
164
|
+
id: z.string(),
|
|
165
|
+
endpointId: z.string(),
|
|
166
|
+
eventId: z.string(),
|
|
167
|
+
eventType: z.string(),
|
|
168
|
+
status: webhookDeliveryStatusSchema,
|
|
169
|
+
attempt: z.number().int(),
|
|
170
|
+
lastStatusCode: z.number().int().nullable(),
|
|
171
|
+
lastError: z.string().nullable(),
|
|
172
|
+
nextAttemptAt: z.string().nullable(),
|
|
173
|
+
deliveredAt: z.string().nullable(),
|
|
174
|
+
createdAt: z.string(),
|
|
175
|
+
updatedAt: z.string(),
|
|
176
|
+
payload: z.record(z.string(), z.unknown())
|
|
177
|
+
});
|
|
178
|
+
export type WebhookDelivery = z.infer<typeof webhookDeliverySchema>;
|
|
179
|
+
|
|
180
|
+
export const webhookDeliveriesListSchema = z.object({
|
|
181
|
+
deliveries: z.array(webhookDeliverySchema),
|
|
182
|
+
nextCursor: z.string().nullable()
|
|
183
|
+
});
|
|
184
|
+
export type WebhookDeliveriesList = z.infer<typeof webhookDeliveriesListSchema>;
|
|
185
|
+
|
|
186
|
+
export const webhookDeliveriesListQuerySchema = cursorPageQuerySchema.extend({
|
|
187
|
+
status: webhookDeliveryStatusSchema.optional()
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
/** The `webhook.ping` attempted inline: its outcome is on the delivery row. */
|
|
191
|
+
export const webhookTestResultSchema = z.object({
|
|
192
|
+
delivery: webhookDeliverySchema
|
|
193
|
+
});
|
|
194
|
+
export type WebhookTestResult = z.infer<typeof webhookTestResultSchema>;
|
|
195
|
+
|
|
196
|
+
/** A subject events can carry and endpoints can filter on: its key (what a filter names), the field of the envelope's `subjects` its value is written under (one id or a list of ids). */
|
|
197
|
+
export const webhookCatalogSubjectSchema = z.object({
|
|
198
|
+
key: z.string(),
|
|
199
|
+
field: z.string(),
|
|
200
|
+
description: z.string(),
|
|
201
|
+
/** The value under `field` is always a list of ids (`projectIds`); else always one id. */
|
|
202
|
+
list: z.boolean()
|
|
203
|
+
});
|
|
204
|
+
export type WebhookCatalogSubject = z.infer<typeof webhookCatalogSubjectSchema>;
|
|
205
|
+
|
|
206
|
+
export const webhookCatalogEventSchema = z.object({
|
|
207
|
+
type: z.string(),
|
|
208
|
+
description: z.string(),
|
|
209
|
+
/** The subject keys this event carries (what a filter can match it on). */
|
|
210
|
+
subjects: z.array(z.string()),
|
|
211
|
+
/** The JSON Schema of the event's `data`. */
|
|
212
|
+
schema: z.record(z.string(), z.unknown())
|
|
213
|
+
});
|
|
214
|
+
export type WebhookCatalogEvent = z.infer<typeof webhookCatalogEventSchema>;
|
|
215
|
+
|
|
216
|
+
export const webhookCatalogSchema = z.object({
|
|
217
|
+
subjects: z.array(webhookCatalogSubjectSchema),
|
|
218
|
+
events: z.array(webhookCatalogEventSchema)
|
|
219
|
+
});
|
|
220
|
+
export type WebhookCatalog = z.infer<typeof webhookCatalogSchema>;
|
|
221
|
+
|
|
222
|
+
/** `{id}` the endpoint (a uuid, as every `{id}` path param). */
|
|
223
|
+
export const webhookParamsSchema = z.object({ id: z.uuid() });
|
|
224
|
+
|
|
225
|
+
/** `{id}` the endpoint, `{deliveryId}` one of its deliveries: both uuids. */
|
|
226
|
+
export const webhookDeliveryParamsSchema = webhookParamsSchema.extend({ deliveryId: z.uuid() });
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
webhookDeliveriesListQuerySchema,
|
|
4
|
+
webhookDeliveryParamsSchema,
|
|
5
|
+
webhookEndpointCreateSchema,
|
|
6
|
+
webhookEndpointCreatedSchema,
|
|
7
|
+
webhookEndpointSchema,
|
|
8
|
+
webhookEndpointUpdateSchema,
|
|
9
|
+
webhookEventPatternSchema,
|
|
10
|
+
webhookFiltersSchema,
|
|
11
|
+
webhookUrlSchema
|
|
12
|
+
} from '../src/index.js';
|
|
13
|
+
import {
|
|
14
|
+
webhookCatalogRoute,
|
|
15
|
+
webhookCreateRoute,
|
|
16
|
+
webhookDeliveryGetRoute,
|
|
17
|
+
webhookRedeliverRoute
|
|
18
|
+
} from '../src/routes/index.js';
|
|
19
|
+
|
|
20
|
+
const ok = (schema: { safeParse: (v: unknown) => { success: boolean } }, v: unknown) =>
|
|
21
|
+
schema.safeParse(v).success;
|
|
22
|
+
|
|
23
|
+
describe('webhook event patterns', () => {
|
|
24
|
+
it('takes an exact type, a family or everything', () => {
|
|
25
|
+
for (const v of ['thing.created', 'thing.*', '*', 'some_thing.status_changed', 'a1.b2']) {
|
|
26
|
+
expect(ok(webhookEventPatternSchema, v), v).toBe(true);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('refuses anything else', () => {
|
|
31
|
+
for (const v of [
|
|
32
|
+
'',
|
|
33
|
+
'thing',
|
|
34
|
+
'*.created',
|
|
35
|
+
'thing.created.extra',
|
|
36
|
+
'Thing.created',
|
|
37
|
+
'1thing.x',
|
|
38
|
+
'thing.',
|
|
39
|
+
'**',
|
|
40
|
+
`a.${'b'.repeat(80)}`
|
|
41
|
+
]) {
|
|
42
|
+
expect(ok(webhookEventPatternSchema, v), v).toBe(false);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('webhook filters', () => {
|
|
48
|
+
it('maps subject keys to non-empty id lists', () => {
|
|
49
|
+
expect(ok(webhookFiltersSchema, {})).toBe(true);
|
|
50
|
+
expect(ok(webhookFiltersSchema, { thing: ['a', 'b'], project: ['p'] })).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('refuses a bad key, an empty list, an empty id, a control character and more than 10 keys', () => {
|
|
54
|
+
expect(ok(webhookFiltersSchema, { Thing: ['a'] })).toBe(false);
|
|
55
|
+
expect(ok(webhookFiltersSchema, { thing: [] })).toBe(false);
|
|
56
|
+
expect(ok(webhookFiltersSchema, { thing: [''] })).toBe(false);
|
|
57
|
+
expect(ok(webhookFiltersSchema, { thing: ['a\u0000'] })).toBe(false);
|
|
58
|
+
expect(ok(webhookFiltersSchema, { thing: Array.from({ length: 101 }, (_, i) => `id${i}`) })).toBe(false);
|
|
59
|
+
const eleven = Object.fromEntries(Array.from({ length: 11 }, (_, i) => [`k${i}`, ['x']]));
|
|
60
|
+
expect(ok(webhookFiltersSchema, eleven)).toBe(false);
|
|
61
|
+
const ten = Object.fromEntries(Array.from({ length: 10 }, (_, i) => [`k${i}`, ['x']]));
|
|
62
|
+
expect(ok(webhookFiltersSchema, ten)).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('webhook URL', () => {
|
|
67
|
+
it('takes http and https URLs', () => {
|
|
68
|
+
expect(ok(webhookUrlSchema, 'https://example.com/hooks')).toBe(true);
|
|
69
|
+
expect(ok(webhookUrlSchema, 'http://example.com:8080/x?y=1')).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('refuses another scheme, credentials, whitespace and a non-URL', () => {
|
|
73
|
+
for (const v of [
|
|
74
|
+
'ftp://example.com',
|
|
75
|
+
'javascript:alert(1)',
|
|
76
|
+
'https://user:pass@example.com',
|
|
77
|
+
'https://user@example.com',
|
|
78
|
+
'https://example.com/a\nb',
|
|
79
|
+
' https://example.com',
|
|
80
|
+
'example.com',
|
|
81
|
+
`https://example.com/${'a'.repeat(2048)}`
|
|
82
|
+
]) {
|
|
83
|
+
expect(ok(webhookUrlSchema, v), v).toBe(false);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('webhook endpoint create and update', () => {
|
|
89
|
+
it('fills the defaults on create', () => {
|
|
90
|
+
const parsed = webhookEndpointCreateSchema.parse({ url: 'https://example.com', events: ['thing.*'] });
|
|
91
|
+
expect(parsed).toEqual({
|
|
92
|
+
url: 'https://example.com',
|
|
93
|
+
description: '',
|
|
94
|
+
events: ['thing.*'],
|
|
95
|
+
filters: {},
|
|
96
|
+
enabled: true
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('needs at least one event, at most 50', () => {
|
|
101
|
+
expect(ok(webhookEndpointCreateSchema, { url: 'https://example.com', events: [] })).toBe(false);
|
|
102
|
+
expect(
|
|
103
|
+
ok(webhookEndpointCreateSchema, { url: 'https://example.com', events: Array(51).fill('thing.created') })
|
|
104
|
+
).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('defaults nothing on update and refuses an empty change', () => {
|
|
108
|
+
expect(webhookEndpointUpdateSchema.parse({ enabled: false })).toEqual({ enabled: false });
|
|
109
|
+
expect(ok(webhookEndpointUpdateSchema, {})).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('the signing secret', () => {
|
|
114
|
+
it('is never part of an endpoint, only of the created answer', () => {
|
|
115
|
+
expect('secret' in webhookEndpointSchema.shape).toBe(false);
|
|
116
|
+
expect('secret' in webhookEndpointCreatedSchema.shape).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('webhook params and queries', () => {
|
|
121
|
+
it('takes uuids only', () => {
|
|
122
|
+
const id = '00000000-0000-4000-8000-000000000000';
|
|
123
|
+
expect(ok(webhookDeliveryParamsSchema, { id, deliveryId: id })).toBe(true);
|
|
124
|
+
expect(ok(webhookDeliveryParamsSchema, { id, deliveryId: 'nope' })).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('filters deliveries by a known status', () => {
|
|
128
|
+
expect(ok(webhookDeliveriesListQuerySchema, { status: 'held' })).toBe(true);
|
|
129
|
+
expect(ok(webhookDeliveriesListQuerySchema, { status: 'lost' })).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('webhook routes', () => {
|
|
134
|
+
it('declares the paths and the answers the server registers', () => {
|
|
135
|
+
expect(webhookCatalogRoute.path).toBe('/webhooks/events');
|
|
136
|
+
expect(Object.keys(webhookCreateRoute.responses)).toContain('201');
|
|
137
|
+
expect(webhookDeliveryGetRoute.path).toBe('/webhooks/{id}/deliveries/{deliveryId}');
|
|
138
|
+
expect(webhookRedeliverRoute.method).toBe('post');
|
|
139
|
+
expect(Object.keys(webhookRedeliverRoute.responses)).toContain('201');
|
|
140
|
+
});
|
|
141
|
+
});
|