@antasphere/chassis-contract 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.
@@ -0,0 +1,175 @@
1
+ import { z } from 'zod';
2
+ import { cursorPageQuerySchema, hasControlChars, plainText } from './common.js';
3
+ /**
4
+ * Outbound webhooks (PRDCT-3482): a URL a workspace registers to be told, by a
5
+ * signed POST, when something happens to the tool's own subjects. The tool
6
+ * declares the event types and the subjects they carry (the catalog); the
7
+ * chassis stores the endpoints, matches the events against them, signs and
8
+ * delivers, and keeps each delivery's history. Owners and admins manage them.
9
+ *
10
+ * The signing secret is NEVER part of an endpoint's shape: it is shown once,
11
+ * at creation and at each rotation, and stored sealed.
12
+ */
13
+ /**
14
+ * What an endpoint subscribes to: an exact event type (`thing.created`), a
15
+ * family (`thing.*`) or everything (`*`).
16
+ */
17
+ export const webhookEventPatternSchema = z
18
+ .string()
19
+ .max(80)
20
+ .regex(/^(\*|[a-z][a-z0-9_]*\.(\*|[a-z][a-z0-9_]*))$/, 'an event type (thing.created), a family (thing.*) or everything (*)');
21
+ /** A subject key of the catalog (`thing`, `project`): lowercase, digits and underscores. */
22
+ export const webhookSubjectKeySchema = z
23
+ .string()
24
+ .max(40)
25
+ .regex(/^[a-z][a-z0-9_]*$/, 'lowercase letters, digits and underscores, starting with a letter');
26
+ /**
27
+ * Narrows a subscription by subject: a subject key mapped to the ids it
28
+ * accepts. AND across keys, OR within a key; an event that does not carry a
29
+ * filtered subject does not match. An empty object filters nothing.
30
+ */
31
+ export const webhookFiltersSchema = z
32
+ .record(webhookSubjectKeySchema, z.array(plainText(1, 200)).min(1).max(100))
33
+ .refine((v) => Object.keys(v).length <= 10, { error: 'at most 10 subject keys' })
34
+ .describe('Subject key → ids; AND across keys, OR within a key; an event that does not carry a filtered subject does not match');
35
+ const UrlParser = globalThis.URL;
36
+ function isHttpUrlWithoutCredentials(value) {
37
+ let parsed;
38
+ try {
39
+ parsed = new UrlParser(value);
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ return ((parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
45
+ parsed.username === '' &&
46
+ parsed.password === '');
47
+ }
48
+ /**
49
+ * Where the deliveries go: an http or https URL without credentials. Whitespace
50
+ * and control characters are refused outright, because the URL parser silently
51
+ * strips tabs and newlines, so the stored string would not be the address
52
+ * delivered to. The address rules (ports, private networks, https on the cloud
53
+ * edition) are the server's, answered `url_refused`.
54
+ */
55
+ export const webhookUrlSchema = z
56
+ .string()
57
+ .max(2048)
58
+ .refine((v) => !/\s/.test(v) && !hasControlChars(v), {
59
+ error: 'must not contain whitespace or control characters',
60
+ abort: true
61
+ })
62
+ .refine(isHttpUrlWithoutCredentials, { error: 'an http or https URL without credentials' });
63
+ export const webhookDeliveryStatusSchema = z.enum(['pending', 'delivered', 'failed', 'held']);
64
+ export const webhookEndpointSchema = z.object({
65
+ id: z.string(),
66
+ url: z.string(),
67
+ description: z.string(),
68
+ events: z.array(z.string()),
69
+ filters: z.record(z.string(), z.array(z.string())),
70
+ enabled: z.boolean(),
71
+ createdBy: z.string().nullable(),
72
+ createdAt: z.string(),
73
+ updatedAt: z.string(),
74
+ /** Set with `disabledReason` when the endpoint was switched off after failing too long. */
75
+ disabledAt: z.string().nullable(),
76
+ disabledReason: z.string().nullable(),
77
+ consecutiveFailures: z.number().int(),
78
+ lastDeliveryAt: z.string().nullable(),
79
+ lastDeliveryStatus: z.enum(['delivered', 'failed']).nullable(),
80
+ /** During a rotation, the previous secret keeps signing until this date. */
81
+ previousSecretExpiresAt: z.string().nullable()
82
+ });
83
+ /** The endpoint as created, with its signing secret (`whsec_…`), shown ONCE. */
84
+ export const webhookEndpointCreatedSchema = webhookEndpointSchema.extend({
85
+ secret: z.string()
86
+ });
87
+ /**
88
+ * The endpoint after a rotation, with its new secret, shown ONCE; the previous
89
+ * secret keeps signing until `previousSecretExpiresAt`.
90
+ */
91
+ export const webhookSecretRotatedSchema = webhookEndpointSchema.extend({
92
+ secret: z.string(),
93
+ previousSecretExpiresAt: z.string()
94
+ });
95
+ const webhookDescriptionSchema = plainText(0, 200);
96
+ const webhookEventsSchema = z.array(webhookEventPatternSchema).min(1).max(50);
97
+ export const webhookEndpointCreateSchema = z.object({
98
+ url: webhookUrlSchema,
99
+ description: webhookDescriptionSchema.default(''),
100
+ events: webhookEventsSchema,
101
+ filters: webhookFiltersSchema.default({}),
102
+ enabled: z.boolean().default(true)
103
+ });
104
+ /** Every field optional, none defaulted (a PATCH never resets what it does not name). */
105
+ export const webhookEndpointUpdateSchema = z
106
+ .object({
107
+ url: webhookUrlSchema.optional(),
108
+ description: webhookDescriptionSchema.optional(),
109
+ events: webhookEventsSchema.optional(),
110
+ filters: webhookFiltersSchema.optional(),
111
+ enabled: z.boolean().optional()
112
+ })
113
+ .refine((b) => Object.values(b).some((v) => v !== undefined), { error: 'nothing to change' });
114
+ export const webhooksListSchema = z.object({
115
+ webhooks: z.array(webhookEndpointSchema),
116
+ nextCursor: z.string().nullable()
117
+ });
118
+ export const webhooksListQuerySchema = cursorPageQuerySchema;
119
+ /**
120
+ * One event sent to one endpoint, with its attempts. `eventId` is the
121
+ * receiver's dedupe key, the same on every attempt and on a redelivery;
122
+ * `lastStatusCode` and `lastError` describe the latest attempt only;
123
+ * `payload` is the envelope as sent.
124
+ */
125
+ export const webhookDeliverySchema = z.object({
126
+ id: z.string(),
127
+ endpointId: z.string(),
128
+ eventId: z.string(),
129
+ eventType: z.string(),
130
+ status: webhookDeliveryStatusSchema,
131
+ attempt: z.number().int(),
132
+ lastStatusCode: z.number().int().nullable(),
133
+ lastError: z.string().nullable(),
134
+ nextAttemptAt: z.string().nullable(),
135
+ deliveredAt: z.string().nullable(),
136
+ createdAt: z.string(),
137
+ updatedAt: z.string(),
138
+ payload: z.record(z.string(), z.unknown())
139
+ });
140
+ export const webhookDeliveriesListSchema = z.object({
141
+ deliveries: z.array(webhookDeliverySchema),
142
+ nextCursor: z.string().nullable()
143
+ });
144
+ export const webhookDeliveriesListQuerySchema = cursorPageQuerySchema.extend({
145
+ status: webhookDeliveryStatusSchema.optional()
146
+ });
147
+ /** The `webhook.ping` attempted inline: its outcome is on the delivery row. */
148
+ export const webhookTestResultSchema = z.object({
149
+ delivery: webhookDeliverySchema
150
+ });
151
+ /** 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). */
152
+ export const webhookCatalogSubjectSchema = z.object({
153
+ key: z.string(),
154
+ field: z.string(),
155
+ description: z.string(),
156
+ /** The value under `field` is always a list of ids (`projectIds`); else always one id. */
157
+ list: z.boolean()
158
+ });
159
+ export const webhookCatalogEventSchema = z.object({
160
+ type: z.string(),
161
+ description: z.string(),
162
+ /** The subject keys this event carries (what a filter can match it on). */
163
+ subjects: z.array(z.string()),
164
+ /** The JSON Schema of the event's `data`. */
165
+ schema: z.record(z.string(), z.unknown())
166
+ });
167
+ export const webhookCatalogSchema = z.object({
168
+ subjects: z.array(webhookCatalogSubjectSchema),
169
+ events: z.array(webhookCatalogEventSchema)
170
+ });
171
+ /** `{id}` the endpoint (a uuid, as every `{id}` path param). */
172
+ export const webhookParamsSchema = z.object({ id: z.uuid() });
173
+ /** `{id}` the endpoint, `{deliveryId}` one of its deliveries: both uuids. */
174
+ export const webhookDeliveryParamsSchema = webhookParamsSchema.extend({ deliveryId: z.uuid() });
175
+ //# sourceMappingURL=webhooks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhooks.js","sourceRoot":"","sources":["../../src/schemas/webhooks.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antasphere/chassis-contract",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "repository": {
6
6
  "type": "git",
package/src/identity.ts CHANGED
@@ -16,8 +16,13 @@ export interface ToolIdentity<TScope extends string = string> {
16
16
  displayName: string;
17
17
  /** API key prefix, without the underscore (`acm` mints `acm_<keyId>_<secret>`). Never change it once keys exist. */
18
18
  apiKeyPrefix: string;
19
- /** The scope vocabulary, by the part each scope plays. `dataExport` is the opt-in full-workspace export. */
20
- scopes: { read: TScope; write: TScope; dataExport: TScope };
19
+ /**
20
+ * The scope vocabulary, by the part each scope plays. `dataExport` is the
21
+ * opt-in full-workspace export; `webhooks` the opt-in management of the
22
+ * workspace's webhook endpoints (PRDCT-3482), absent on a tool that opens
23
+ * them to no machine.
24
+ */
25
+ scopes: { read: TScope; write: TScope; dataExport: TScope; webhooks?: TScope };
21
26
  /** How the OpenAPI document names the CLI key's grant (`things:read+write`). A literal: a contraction, not a join. */
22
27
  cliKeyScopesLabel: string;
23
28
  /**
package/src/index.ts CHANGED
@@ -31,6 +31,7 @@ export * from './schemas/break-glass.js';
31
31
  export * from './schemas/files.js';
32
32
  export * from './schemas/projects.js';
33
33
  export * from './schemas/teams.js';
34
+ export * from './schemas/webhooks.js';
34
35
  export * from './schemas/demo-passes.js';
35
36
  export * from './seams.js';
36
37
  export * from './entitlements.js';
@@ -78,6 +78,22 @@ import {
78
78
  teamsListSchema,
79
79
  teamUpdateSchema
80
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';
81
97
 
82
98
  /**
83
99
  * Server-only entry: route contracts for @hono/zod-openapi. Importing this
@@ -680,6 +696,173 @@ export const teamMemberRemoveRoute = createRoute({
680
696
  responses: { 200: jsonBody(teamMemberSchema, 'The removed team member'), ...teamErrors }
681
697
  });
682
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
+
683
866
  // ── Demo passes ──────────────────────────────────────────────────────────────
684
867
  // A link an owner mints so that one member is signed in without a password,
685
868
  // for a demonstration (the demo pass spec). The three routes are registered
@@ -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() });