@kelpie/schemas 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +33 -0
  3. package/dist/account.d.ts +79 -0
  4. package/dist/account.d.ts.map +1 -0
  5. package/dist/account.js +66 -0
  6. package/dist/account.js.map +1 -0
  7. package/dist/activity.d.ts +23 -0
  8. package/dist/activity.d.ts.map +1 -0
  9. package/dist/activity.js +27 -0
  10. package/dist/activity.js.map +1 -0
  11. package/dist/agentTask.d.ts +97 -0
  12. package/dist/agentTask.d.ts.map +1 -0
  13. package/dist/agentTask.js +127 -0
  14. package/dist/agentTask.js.map +1 -0
  15. package/dist/candidate.d.ts +39 -0
  16. package/dist/candidate.d.ts.map +1 -0
  17. package/dist/candidate.js +40 -0
  18. package/dist/candidate.js.map +1 -0
  19. package/dist/company.d.ts +38 -0
  20. package/dist/company.d.ts.map +1 -0
  21. package/dist/company.js +57 -0
  22. package/dist/company.js.map +1 -0
  23. package/dist/dashboard.d.ts +112 -0
  24. package/dist/dashboard.d.ts.map +1 -0
  25. package/dist/dashboard.js +165 -0
  26. package/dist/dashboard.js.map +1 -0
  27. package/dist/deal.d.ts +40 -0
  28. package/dist/deal.d.ts.map +1 -0
  29. package/dist/deal.js +59 -0
  30. package/dist/deal.js.map +1 -0
  31. package/dist/decision.d.ts +46 -0
  32. package/dist/decision.d.ts.map +1 -0
  33. package/dist/decision.js +48 -0
  34. package/dist/decision.js.map +1 -0
  35. package/dist/form.d.ts +97 -0
  36. package/dist/form.d.ts.map +1 -0
  37. package/dist/form.js +98 -0
  38. package/dist/form.js.map +1 -0
  39. package/dist/formSubmission.d.ts +47 -0
  40. package/dist/formSubmission.d.ts.map +1 -0
  41. package/dist/formSubmission.js +47 -0
  42. package/dist/formSubmission.js.map +1 -0
  43. package/dist/handbookPage.d.ts +52 -0
  44. package/dist/handbookPage.d.ts.map +1 -0
  45. package/dist/handbookPage.js +56 -0
  46. package/dist/handbookPage.js.map +1 -0
  47. package/dist/importExport.d.ts +143 -0
  48. package/dist/importExport.d.ts.map +1 -0
  49. package/dist/importExport.js +239 -0
  50. package/dist/importExport.js.map +1 -0
  51. package/dist/index.d.ts +75 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +45 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/invite.d.ts +35 -0
  56. package/dist/invite.d.ts.map +1 -0
  57. package/dist/invite.js +31 -0
  58. package/dist/invite.js.map +1 -0
  59. package/dist/mcpTool.d.ts +26 -0
  60. package/dist/mcpTool.d.ts.map +1 -0
  61. package/dist/mcpTool.js +13 -0
  62. package/dist/mcpTool.js.map +1 -0
  63. package/dist/member.d.ts +26 -0
  64. package/dist/member.d.ts.map +1 -0
  65. package/dist/member.js +24 -0
  66. package/dist/member.js.map +1 -0
  67. package/dist/moduleSetting.d.ts +19 -0
  68. package/dist/moduleSetting.d.ts.map +1 -0
  69. package/dist/moduleSetting.js +16 -0
  70. package/dist/moduleSetting.js.map +1 -0
  71. package/dist/note.d.ts +34 -0
  72. package/dist/note.d.ts.map +1 -0
  73. package/dist/note.js +35 -0
  74. package/dist/note.js.map +1 -0
  75. package/dist/opportunity.d.ts +30 -0
  76. package/dist/opportunity.d.ts.map +1 -0
  77. package/dist/opportunity.js +41 -0
  78. package/dist/opportunity.js.map +1 -0
  79. package/dist/partnership.d.ts +36 -0
  80. package/dist/partnership.d.ts.map +1 -0
  81. package/dist/partnership.js +50 -0
  82. package/dist/partnership.js.map +1 -0
  83. package/dist/person.d.ts +45 -0
  84. package/dist/person.d.ts.map +1 -0
  85. package/dist/person.js +60 -0
  86. package/dist/person.js.map +1 -0
  87. package/dist/pipelineStage.d.ts +29 -0
  88. package/dist/pipelineStage.d.ts.map +1 -0
  89. package/dist/pipelineStage.js +38 -0
  90. package/dist/pipelineStage.js.map +1 -0
  91. package/dist/planItem.d.ts +43 -0
  92. package/dist/planItem.d.ts.map +1 -0
  93. package/dist/planItem.js +44 -0
  94. package/dist/planItem.js.map +1 -0
  95. package/dist/position.d.ts +22 -0
  96. package/dist/position.d.ts.map +1 -0
  97. package/dist/position.js +25 -0
  98. package/dist/position.js.map +1 -0
  99. package/dist/raise.d.ts +38 -0
  100. package/dist/raise.d.ts.map +1 -0
  101. package/dist/raise.js +53 -0
  102. package/dist/raise.js.map +1 -0
  103. package/dist/role.d.ts +21 -0
  104. package/dist/role.d.ts.map +1 -0
  105. package/dist/role.js +21 -0
  106. package/dist/role.js.map +1 -0
  107. package/dist/search.d.ts +41 -0
  108. package/dist/search.d.ts.map +1 -0
  109. package/dist/search.js +41 -0
  110. package/dist/search.js.map +1 -0
  111. package/dist/session.d.ts +92 -0
  112. package/dist/session.d.ts.map +1 -0
  113. package/dist/session.js +67 -0
  114. package/dist/session.js.map +1 -0
  115. package/dist/values.d.ts +216 -0
  116. package/dist/values.d.ts.map +1 -0
  117. package/dist/values.js +329 -0
  118. package/dist/values.js.map +1 -0
  119. package/dist/webhook.d.ts +85 -0
  120. package/dist/webhook.d.ts.map +1 -0
  121. package/dist/webhook.js +75 -0
  122. package/dist/webhook.js.map +1 -0
  123. package/dist/wire.d.ts +40 -0
  124. package/dist/wire.d.ts.map +1 -0
  125. package/dist/wire.js +40 -0
  126. package/dist/wire.js.map +1 -0
  127. package/package.json +51 -0
  128. package/src/account.ts +149 -0
  129. package/src/activity.ts +52 -0
  130. package/src/agentTask.ts +254 -0
  131. package/src/candidate.ts +83 -0
  132. package/src/company.ts +98 -0
  133. package/src/dashboard.ts +307 -0
  134. package/src/deal.ts +102 -0
  135. package/src/decision.ts +106 -0
  136. package/src/form.ts +217 -0
  137. package/src/formSubmission.ts +99 -0
  138. package/src/handbookPage.ts +99 -0
  139. package/src/importExport.ts +339 -0
  140. package/src/index.ts +324 -0
  141. package/src/invite.ts +69 -0
  142. package/src/mcpTool.ts +32 -0
  143. package/src/member.ts +53 -0
  144. package/src/moduleSetting.ts +37 -0
  145. package/src/note.ts +73 -0
  146. package/src/opportunity.ts +74 -0
  147. package/src/partnership.ts +89 -0
  148. package/src/person.ts +116 -0
  149. package/src/pipelineStage.ts +71 -0
  150. package/src/planItem.ts +91 -0
  151. package/src/position.ts +50 -0
  152. package/src/raise.ts +94 -0
  153. package/src/role.ts +45 -0
  154. package/src/search.ts +86 -0
  155. package/src/session.ts +177 -0
  156. package/src/values.ts +427 -0
  157. package/src/webhook.ts +164 -0
  158. package/src/wire.ts +51 -0
package/src/values.ts ADDED
@@ -0,0 +1,427 @@
1
+ /**
2
+ * Fixed value sets shared by the wire schemas and by any UI that renders a
3
+ * dropdown over one.
4
+ *
5
+ * This is the only copy. `@kelpie/server`'s module schemas import from here and
6
+ * re-export, so one array drives a table's check constraint, the route's Zod
7
+ * enum, and the browser's decoder. The dependency runs one way: the server may
8
+ * import this package, and this package depends on Zod and nothing else, which
9
+ * is what keeps it usable from a browser bundle and from the cloud repo.
10
+ */
11
+
12
+ export const PREFERRED_CHANNELS = ['email', 'call', 'linkedin'] as const
13
+ export const INFLUENCE_LEVELS = [
14
+ 'champion',
15
+ 'decision_maker',
16
+ 'influencer',
17
+ 'blocker',
18
+ 'end_user',
19
+ ] as const
20
+ export const RELATIONSHIP_LEVELS = ['cold', 'warm', 'strong'] as const
21
+
22
+ export type PreferredChannel = (typeof PREFERRED_CHANNELS)[number]
23
+ export type Influence = (typeof INFLUENCE_LEVELS)[number]
24
+ export type Relationship = (typeof RELATIONSHIP_LEVELS)[number]
25
+
26
+ export const COMPANY_STAGES = ['startup', 'growth', 'enterprise', 'other'] as const
27
+ export const SIZE_BANDS = ['1-10', '11-50', '51-200', '201+'] as const
28
+ export const ACCOUNT_TYPES = ['prospect', 'customer', 'partner', 'investor', 'other'] as const
29
+ export const ICP_FITS = ['high', 'medium', 'low', 'unknown'] as const
30
+
31
+ export type CompanyStage = (typeof COMPANY_STAGES)[number]
32
+ export type SizeBand = (typeof SIZE_BANDS)[number]
33
+ export type AccountType = (typeof ACCOUNT_TYPES)[number]
34
+ export type IcpFit = (typeof ICP_FITS)[number]
35
+
36
+ /** Networks a person can be linked on. One list beats a column per network. */
37
+ export const SOCIAL_NETWORK_IDS = [
38
+ 'angellist',
39
+ 'bluesky',
40
+ 'crunchbase',
41
+ 'facebook',
42
+ 'github',
43
+ 'instagram',
44
+ 'linkedin',
45
+ 'mastodon',
46
+ 'medium',
47
+ 'substack',
48
+ 'threads',
49
+ 'tiktok',
50
+ 'twitter',
51
+ 'youtube',
52
+ 'other',
53
+ ] as const
54
+
55
+ export type SocialNetworkId = (typeof SOCIAL_NETWORK_IDS)[number]
56
+
57
+ /** Display names for `SOCIAL_NETWORK_IDS`, in the same order. */
58
+ export const SOCIAL_NETWORK_LABELS: Readonly<Record<SocialNetworkId, string>> = {
59
+ angellist: 'AngelList',
60
+ bluesky: 'Bluesky',
61
+ crunchbase: 'Crunchbase',
62
+ facebook: 'Facebook',
63
+ github: 'GitHub',
64
+ instagram: 'Instagram',
65
+ linkedin: 'LinkedIn',
66
+ mastodon: 'Mastodon',
67
+ medium: 'Medium',
68
+ substack: 'Substack',
69
+ threads: 'Threads',
70
+ tiktok: 'TikTok',
71
+ twitter: 'X / Twitter',
72
+ youtube: 'YouTube',
73
+ other: 'Other',
74
+ }
75
+
76
+ /**
77
+ * The record types a detail page exists for, and therefore the ones a UI module
78
+ * can add a tab or a sidebar card to. Lived in `@kelpie/ui` until this package
79
+ * gave it a home both the UI and the cloud repo can import.
80
+ *
81
+ * Narrower than the server's `RECORD_OBJECT_TYPES`, which is the list a
82
+ * `record.*` event may carry. A Position, a Form, and a Handbook page are all
83
+ * written and all publish events, but none of them has a detail page for a
84
+ * module to hang anything off.
85
+ */
86
+ export const EXTENSIBLE_RECORD_TYPES = [
87
+ 'person',
88
+ 'company',
89
+ 'deal',
90
+ 'opportunity',
91
+ 'partnership',
92
+ 'raise',
93
+ 'role',
94
+ 'candidate',
95
+ ] as const
96
+
97
+ export type ExtensibleRecordType = (typeof EXTENSIBLE_RECORD_TYPES)[number]
98
+
99
+ /**
100
+ * The record types a note, activity, decision, or plan item attaches to.
101
+ *
102
+ * Not the same list as `EXTENSIBLE_RECORD_TYPES`: a Role is a detail page a UI
103
+ * module can extend, but nothing attaches a note to it. Interview notes go on
104
+ * the Candidate, which is the person-and-role link.
105
+ */
106
+ export const RECORD_TARGET_TYPES = [
107
+ 'person',
108
+ 'company',
109
+ 'deal',
110
+ 'opportunity',
111
+ 'partnership',
112
+ 'raise',
113
+ 'candidate',
114
+ ] as const
115
+
116
+ export type RecordTargetType = (typeof RECORD_TARGET_TYPES)[number]
117
+
118
+ /**
119
+ * What `GET /v1/search` looks through, and the order its groups come back in.
120
+ *
121
+ * Handbook leads because a question phrased in words rather than names is usually
122
+ * a question the handbook answers. The rest follow the sidebar.
123
+ *
124
+ * Neither of the lists above fits. `RECORD_OBJECT_TYPES` on the server has no
125
+ * Decision and does have Position, Candidate and Form: a Position is reachable
126
+ * only as the person holding it, and nobody searches for a Candidate by name
127
+ * without finding the Person first.
128
+ */
129
+ export const SEARCH_COLLECTIONS = [
130
+ 'handbook_page',
131
+ 'person',
132
+ 'role',
133
+ 'company',
134
+ 'deal',
135
+ 'opportunity',
136
+ 'raise',
137
+ 'partnership',
138
+ 'decision',
139
+ ] as const
140
+
141
+ export type SearchCollection = (typeof SEARCH_COLLECTIONS)[number]
142
+
143
+ /** Whether a Role is still being hired for. */
144
+ export const ROLE_STATUSES = ['open', 'closed'] as const
145
+
146
+ export type RoleStatus = (typeof ROLE_STATUSES)[number]
147
+
148
+ export const ROLE_STATUS_LABELS: Readonly<Record<RoleStatus, string>> = {
149
+ open: 'Open',
150
+ closed: 'Closed',
151
+ }
152
+
153
+ /**
154
+ * Where a Candidate stands with the Role they are attached to.
155
+ *
156
+ * This is the Person↔Role link's state, never a column on Person: the same
157
+ * person can be in process for one role and in the nurture pile for another.
158
+ */
159
+ export const CANDIDATE_STATUSES = [
160
+ 'in_process',
161
+ 'nurture',
162
+ 'hired',
163
+ 'passed',
164
+ 'withdrawn',
165
+ ] as const
166
+
167
+ export type CandidateStatus = (typeof CANDIDATE_STATUSES)[number]
168
+
169
+ export const CANDIDATE_STATUS_LABELS: Readonly<Record<CandidateStatus, string>> = {
170
+ in_process: 'In process',
171
+ nurture: 'Nurture',
172
+ hired: 'Hired',
173
+ passed: 'Passed',
174
+ withdrawn: 'Withdrawn',
175
+ }
176
+
177
+ /** The one status that carries an interview stage. Every other status clears it. */
178
+ export const IN_PROCESS: CandidateStatus = 'in_process'
179
+
180
+ /**
181
+ * How far through interviewing a candidate is, in order. Meaningful only while
182
+ * the candidate is in process, which is why `interview_stage` is nullable.
183
+ */
184
+ export const INTERVIEW_STAGES = ['sourced', 'screen', 'interview', 'offer'] as const
185
+
186
+ export type InterviewStage = (typeof INTERVIEW_STAGES)[number]
187
+
188
+ export const INTERVIEW_STAGE_LABELS: Readonly<Record<InterviewStage, string>> = {
189
+ sourced: 'Sourced',
190
+ screen: 'Screen',
191
+ interview: 'Interview',
192
+ offer: 'Offer',
193
+ }
194
+
195
+ /** Where a candidate enters the process when no stage is named. */
196
+ export const FIRST_INTERVIEW_STAGE: InterviewStage = INTERVIEW_STAGES[0]
197
+
198
+ /**
199
+ * The four pipelines whose board columns live in `pipeline_stages`. A Deal moves
200
+ * through `deal` stages and so on; the kinds are fixed even though the stages
201
+ * within each are workspace-configurable.
202
+ */
203
+ export const PIPELINE_KINDS = ['deal', 'opportunity', 'raise', 'partnership'] as const
204
+
205
+ export type PipelineKind = (typeof PIPELINE_KINDS)[number]
206
+
207
+ /** Display names for `PIPELINE_KINDS`. "Fundraising" is what the nav calls a Raise. */
208
+ export const PIPELINE_KIND_LABELS: Readonly<Record<PipelineKind, string>> = {
209
+ deal: 'Deal',
210
+ opportunity: 'Opportunity',
211
+ raise: 'Fundraising',
212
+ partnership: 'Partnership',
213
+ }
214
+
215
+ /**
216
+ * How far along a plan item is. Stored, never derived: whether something is
217
+ * overdue is a question about its date, and whether it is finished is a question
218
+ * about this column, and conflating the two would make a late-but-done item
219
+ * shout for attention forever.
220
+ */
221
+ export const PLAN_ITEM_STATUSES = ['todo', 'in_progress', 'done'] as const
222
+
223
+ export type PlanItemStatus = (typeof PLAN_ITEM_STATUSES)[number]
224
+
225
+ export const PLAN_ITEM_STATUS_LABELS: Readonly<Record<PlanItemStatus, string>> = {
226
+ todo: 'To do',
227
+ in_progress: 'In progress',
228
+ done: 'Done',
229
+ }
230
+
231
+ /** The statuses that still need doing. `plan.completed` fires on leaving this set. */
232
+ export const OPEN_PLAN_ITEM_STATUSES = ['todo', 'in_progress'] as const
233
+
234
+ /**
235
+ * What an activity says happened. `created`, `updated`, `stage_changed`,
236
+ * `note_added`, `linked` and `unlinked` are emitted by the server; `email`,
237
+ * `call` and `meeting` are logged history an integration or an agent supplies.
238
+ *
239
+ * `unlinked` is only filed when a link is deleted through its own route. A link
240
+ * that dies with either of its ends never reaches a service, so the timeline
241
+ * that survives keeps the `linked` row without a counterpart. That row is
242
+ * history rather than a claim about the present, so it stays true either way.
243
+ */
244
+ export const ACTIVITY_KINDS = [
245
+ 'created',
246
+ 'updated',
247
+ 'stage_changed',
248
+ 'note_added',
249
+ 'email',
250
+ 'call',
251
+ 'meeting',
252
+ 'linked',
253
+ 'unlinked',
254
+ ] as const
255
+
256
+ export type ActivityKind = (typeof ACTIVITY_KINDS)[number]
257
+
258
+ export const MEMBER_ROLES = ['owner', 'admin', 'member'] as const
259
+
260
+ export type MemberRole = (typeof MEMBER_ROLES)[number]
261
+
262
+ /**
263
+ * Roles an invitation may offer. Ownership is created with the workspace or
264
+ * transferred between members; it is never invited, and the `invites.role` check
265
+ * constraint says the same.
266
+ */
267
+ export const INVITABLE_ROLES = ['admin', 'member'] as const
268
+
269
+ export type InvitableRole = (typeof INVITABLE_ROLES)[number]
270
+
271
+ /** What a pending invitation is called once its `expires_at` has passed. */
272
+ export const INVITE_STATUSES = ['pending', 'expired'] as const
273
+
274
+ export type InviteStatus = (typeof INVITE_STATUSES)[number]
275
+
276
+ /**
277
+ * Light, dark, or whatever the operating system says.
278
+ *
279
+ * `system` is a stored answer rather than the absence of one: a reader who has
280
+ * chosen to follow the machine has expressed a preference, and it has to survive
281
+ * a move to a browser whose machine currently says something else.
282
+ */
283
+ export const THEME_PREFERENCES = ['system', 'light', 'dark'] as const
284
+
285
+ export type ThemePreference = (typeof THEME_PREFERENCES)[number]
286
+
287
+ /** A paused form still exists and still renders; its submit answers 409 (`forms.md`). */
288
+ export const FORM_STATUSES = ['active', 'paused'] as const
289
+
290
+ export type FormStatus = (typeof FORM_STATUSES)[number]
291
+
292
+ /**
293
+ * What a field renders as in the embed. Deliberately short: `forms.md` puts file
294
+ * uploads, multi-page forms and branching out of scope, and every type here is
295
+ * one `<input>`, `<textarea>` or `<select>`.
296
+ */
297
+ export const FORM_FIELD_TYPES = ['text', 'email', 'textarea', 'select'] as const
298
+
299
+ export type FormFieldType = (typeof FORM_FIELD_TYPES)[number]
300
+
301
+ /**
302
+ * Where a field's answer lands on submit.
303
+ *
304
+ * `position.title` rather than a person field, because a job title belongs to the
305
+ * Person↔Company link and nowhere else. `submission` stores the answer without
306
+ * writing any CRM record, which is what a free-text "How can we help?" wants.
307
+ */
308
+ export const FORM_FIELD_MAP_TARGETS = [
309
+ 'person.name',
310
+ 'person.email',
311
+ 'company.name',
312
+ 'company.domain',
313
+ 'position.title',
314
+ 'deal.name',
315
+ 'submission',
316
+ ] as const
317
+
318
+ export type FormFieldMapTarget = (typeof FORM_FIELD_MAP_TARGETS)[number]
319
+
320
+ export const FORM_FIELD_MAP_TARGET_LABELS: Readonly<Record<FormFieldMapTarget, string>> = {
321
+ 'person.name': 'Person · name',
322
+ 'person.email': 'Person · email',
323
+ 'company.name': 'Company · name',
324
+ 'company.domain': 'Company · domain',
325
+ 'position.title': 'Position · title',
326
+ 'deal.name': 'Deal · name',
327
+ submission: 'Submission only',
328
+ }
329
+
330
+ /** The one mapping a form cannot process without, and may carry at most once. */
331
+ export const PERSON_EMAIL_TARGET: FormFieldMapTarget = 'person.email'
332
+
333
+ /**
334
+ * How a select option's stored key should be read back.
335
+ *
336
+ * The answer map is `fieldId → string` on the wire either way; this says what
337
+ * the string means, so a consumer knows `"true"` was a checkbox and not a word.
338
+ */
339
+ export const FORM_OPTION_VALUE_TYPES = ['string', 'number', 'boolean'] as const
340
+
341
+ export type FormOptionValueType = (typeof FORM_OPTION_VALUE_TYPES)[number]
342
+
343
+ /**
344
+ * The domain events a webhook can subscribe to.
345
+ *
346
+ * A subset of the server's event catalog on purpose: the ticket's minimum
347
+ * viable set, and the events whose payloads describe something a receiver
348
+ * outside Kelpie can act on. The rest of the catalog (`stage.changed`,
349
+ * `note.added`, membership and workspace events) is not deliverable yet, so it
350
+ * is not offered rather than accepted and silently never sent.
351
+ */
352
+ export const WEBHOOK_EVENTS = [
353
+ 'record.created',
354
+ 'record.updated',
355
+ 'record.deleted',
356
+ 'form.submitted',
357
+ ] as const
358
+
359
+ export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number]
360
+
361
+ /**
362
+ * `failing` is the delivery engine's, not the customer's: it means the last
363
+ * delivery exhausted its attempts. `paused` is the customer's, and stops
364
+ * delivery entirely. A failing webhook keeps being tried, which is what lets it
365
+ * return to `active` on its own once the endpoint recovers.
366
+ */
367
+ export const WEBHOOK_STATUSES = ['active', 'failing', 'paused'] as const
368
+
369
+ export type WebhookStatus = (typeof WEBHOOK_STATUSES)[number]
370
+
371
+ /** What a `PATCH` may set. `failing` is a report on the endpoint, not a request. */
372
+ export const WEBHOOK_SETTABLE_STATUSES = ['active', 'paused'] as const
373
+
374
+ export type WebhookSettableStatus = (typeof WEBHOOK_SETTABLE_STATUSES)[number]
375
+
376
+ /** A delivery is only logged once it has settled, so there is no pending value. */
377
+ export const WEBHOOK_DELIVERY_STATUSES = ['success', 'failed'] as const
378
+
379
+ export type WebhookDeliveryStatus = (typeof WEBHOOK_DELIVERY_STATUSES)[number]
380
+
381
+ export const WEBHOOK_STATUS_LABELS: Readonly<Record<WebhookStatus, string>> = {
382
+ active: 'Active',
383
+ failing: 'Failing',
384
+ paused: 'Paused',
385
+ }
386
+
387
+ /**
388
+ * What an agent task can point at: the seven attachable record types, plus
389
+ * three surfaces that carry tasks without being note targets — a Role, a
390
+ * Handbook page, and the workspace itself (`agent-tasks.md`).
391
+ */
392
+ export const AGENT_TASK_TARGET_TYPES = [
393
+ 'person',
394
+ 'company',
395
+ 'deal',
396
+ 'opportunity',
397
+ 'partnership',
398
+ 'raise',
399
+ 'candidate',
400
+ 'role',
401
+ 'handbook',
402
+ 'workspace',
403
+ ] as const
404
+
405
+ export type AgentTaskTargetType = (typeof AGENT_TASK_TARGET_TYPES)[number]
406
+
407
+ /** `primary` shows as a compact action; `overflow` lives under "More". */
408
+ export const AGENT_TASK_PLACEMENTS = ['primary', 'overflow'] as const
409
+
410
+ export type AgentTaskPlacement = (typeof AGENT_TASK_PLACEMENTS)[number]
411
+
412
+ /**
413
+ * Where a run's dispatch stands. The lifecycle describes the POST to the
414
+ * registered agent, not the agent's own work: Kelpie hands the prompt over and
415
+ * records whether the handover landed. `agent-tasks.md` defines no callback for
416
+ * an agent to report completion, so anything past the dispatch would be a guess.
417
+ */
418
+ export const AGENT_RUN_STATUSES = ['queued', 'running', 'succeeded', 'failed'] as const
419
+
420
+ export type AgentRunStatus = (typeof AGENT_RUN_STATUSES)[number]
421
+
422
+ export const AGENT_RUN_STATUS_LABELS: Readonly<Record<AgentRunStatus, string>> = {
423
+ queued: 'Queued',
424
+ running: 'Running',
425
+ succeeded: 'Succeeded',
426
+ failed: 'Failed',
427
+ }
package/src/webhook.ts ADDED
@@ -0,0 +1,164 @@
1
+ import { z } from 'zod'
2
+
3
+ import { WEBHOOK_DELIVERY_STATUSES, WEBHOOK_EVENTS, WEBHOOK_STATUSES } from './values.ts'
4
+ import type {
5
+ WebhookDeliveryStatus,
6
+ WebhookEvent,
7
+ WebhookSettableStatus,
8
+ WebhookStatus,
9
+ } from './values.ts'
10
+ import { definedFields, idSchema, nullableTimestampSchema, timestampSchema } from './wire.ts'
11
+
12
+ /**
13
+ * Wire and write shapes for `/v1/webhooks`.
14
+ *
15
+ * The signing secret appears exactly once, in the `201` that creates the
16
+ * webhook, and never again — the same contract API keys have. Every later read
17
+ * carries only `secret_prefix`, which is enough to tell two registrations
18
+ * apart in a list and useless to anyone who intercepts it.
19
+ */
20
+
21
+ export interface Webhook {
22
+ readonly id: string
23
+ readonly url: string
24
+ readonly events: readonly WebhookEvent[]
25
+ /** The leading characters of the secret, for recognising a registration. */
26
+ readonly secretPrefix: string
27
+ readonly status: WebhookStatus
28
+ /** Derived from the delivery log, not stored: null until the first delivery settles. */
29
+ readonly lastDeliveryAt: Date | null
30
+ readonly lastDeliveryStatus: WebhookDeliveryStatus | null
31
+ readonly createdAt: Date
32
+ readonly updatedAt: Date
33
+ }
34
+
35
+ /** The one response that carries the signing secret. Nothing can retrieve it later. */
36
+ export interface CreatedWebhook extends Webhook {
37
+ readonly secret: string
38
+ }
39
+
40
+ /** One settled delivery: an event sent to one webhook, after its retries. */
41
+ export interface WebhookDelivery {
42
+ readonly id: string
43
+ readonly webhookId: string
44
+ readonly event: WebhookEvent
45
+ /** Exactly the JSON that was signed and sent. */
46
+ readonly payload: unknown
47
+ readonly status: WebhookDeliveryStatus
48
+ /** How many HTTP requests it took. At least 1; more means retries. */
49
+ readonly attempts: number
50
+ /** When it succeeded, null when it never did. */
51
+ readonly deliveredAt: Date | null
52
+ readonly createdAt: Date
53
+ }
54
+
55
+ const webhookWire = {
56
+ id: idSchema,
57
+ url: z.string(),
58
+ events: z.array(z.enum(WEBHOOK_EVENTS)),
59
+ secret_prefix: z.string(),
60
+ status: z.enum(WEBHOOK_STATUSES),
61
+ last_delivery_at: nullableTimestampSchema,
62
+ last_delivery_status: z.enum(WEBHOOK_DELIVERY_STATUSES).nullable(),
63
+ created_at: timestampSchema,
64
+ updated_at: timestampSchema,
65
+ }
66
+
67
+ const webhookWireSchema = z.object(webhookWire)
68
+
69
+ function toWebhook(wire: z.output<typeof webhookWireSchema>): Webhook {
70
+ return {
71
+ id: wire.id,
72
+ url: wire.url,
73
+ events: wire.events,
74
+ secretPrefix: wire.secret_prefix,
75
+ status: wire.status,
76
+ lastDeliveryAt: wire.last_delivery_at,
77
+ lastDeliveryStatus: wire.last_delivery_status,
78
+ createdAt: wire.created_at,
79
+ updatedAt: wire.updated_at,
80
+ }
81
+ }
82
+
83
+ export const webhookSchema: z.ZodType<Webhook, unknown> = webhookWireSchema.transform(toWebhook)
84
+
85
+ export const createdWebhookSchema: z.ZodType<CreatedWebhook, unknown> = z
86
+ .object({ ...webhookWire, secret: z.string() })
87
+ .transform((wire): CreatedWebhook => ({ ...toWebhook(wire), secret: wire.secret }))
88
+
89
+ export const webhookDeliverySchema: z.ZodType<WebhookDelivery, unknown> = z
90
+ .object({
91
+ id: idSchema,
92
+ webhook_id: idSchema,
93
+ event: z.enum(WEBHOOK_EVENTS),
94
+ payload: z.unknown(),
95
+ status: z.enum(WEBHOOK_DELIVERY_STATUSES),
96
+ attempts: z.number().int(),
97
+ delivered_at: nullableTimestampSchema,
98
+ created_at: timestampSchema,
99
+ })
100
+ .transform(
101
+ (wire): WebhookDelivery => ({
102
+ id: wire.id,
103
+ webhookId: wire.webhook_id,
104
+ event: wire.event,
105
+ payload: wire.payload,
106
+ status: wire.status,
107
+ attempts: wire.attempts,
108
+ deliveredAt: wire.delivered_at,
109
+ createdAt: wire.created_at,
110
+ }),
111
+ )
112
+
113
+ export interface CreateWebhookInput {
114
+ readonly url: string
115
+ readonly events: readonly WebhookEvent[]
116
+ }
117
+
118
+ /**
119
+ * `status` takes only the two values a customer controls. `failing` is what the
120
+ * delivery engine reports about the endpoint, so setting it by hand would be
121
+ * asserting something only the engine can know.
122
+ */
123
+ export interface WebhookInput {
124
+ readonly url?: string
125
+ readonly events?: readonly WebhookEvent[]
126
+ readonly status?: WebhookSettableStatus
127
+ }
128
+
129
+ /**
130
+ * How long a rotation may sign under both secrets.
131
+ *
132
+ * Long enough to cover a working day, because the window has to span a customer
133
+ * noticing, changing their configuration and getting a deploy out, and short
134
+ * enough that a leaked secret is not honoured for a week.
135
+ *
136
+ * Shared rather than duplicated: the service computes the expiry from it and the
137
+ * browser tells the customer what they are choosing, and the two disagreeing
138
+ * would mean a checkbox that promises a window nothing implements.
139
+ */
140
+ export const WEBHOOK_SECRET_OVERLAP_HOURS = 24
141
+
142
+ /**
143
+ * Replacing a webhook's signing secret.
144
+ *
145
+ * `overlap` keeps the old secret valid for a further
146
+ * `WEBHOOK_SECRET_OVERLAP_HOURS`, so a delivery is signed under both and an
147
+ * endpoint that has not been redeployed still verifies. Off means the old secret
148
+ * stops working at once, and deliveries fail until the new one is live.
149
+ */
150
+ export interface RotateWebhookSecretInput {
151
+ readonly overlap: boolean
152
+ }
153
+
154
+ export function rotateWebhookSecretBody(input: RotateWebhookSecretInput): unknown {
155
+ return { overlap: input.overlap }
156
+ }
157
+
158
+ export function createWebhookBody(input: CreateWebhookInput): unknown {
159
+ return { url: input.url, events: input.events }
160
+ }
161
+
162
+ export function webhookBody(input: WebhookInput): unknown {
163
+ return definedFields({ url: input.url, events: input.events, status: input.status })
164
+ }
package/src/wire.ts ADDED
@@ -0,0 +1,51 @@
1
+ import { z } from 'zod'
2
+
3
+ /**
4
+ * Pieces every resource schema in this package is built from.
5
+ *
6
+ * `api.md` puts `snake_case` on the wire and `camelCase` in TypeScript, and says
7
+ * the mapping happens at the boundary. For a client, this package is that
8
+ * boundary: each resource declares a schema over the wire shape and transforms
9
+ * it into the record the UI holds, so no component ever reads a `snake_case`
10
+ * key.
11
+ */
12
+
13
+ /** An `id` from `api.md`: `<prefix>_<ulid>`. Only checked for non-emptiness. */
14
+ export const idSchema = z.string().min(1)
15
+
16
+ /** An ISO 8601 UTC timestamp, read as a `Date`. */
17
+ export const timestampSchema = z.iso.datetime().transform((value) => new Date(value))
18
+
19
+ /** A nullable timestamp: `null` on the wire stays `null`, it does not become an epoch. */
20
+ export const nullableTimestampSchema = z.iso
21
+ .datetime()
22
+ .nullable()
23
+ .transform((value) => (value === null ? null : new Date(value)))
24
+
25
+ /**
26
+ * Fields every CRM record carries. Spread into a resource's wire object rather
27
+ * than merged after the transform, because a transformed schema can no longer
28
+ * be extended.
29
+ */
30
+ export const recordTimestamps = {
31
+ created_at: timestampSchema,
32
+ updated_at: timestampSchema,
33
+ }
34
+
35
+ /** What `recordTimestamps` becomes once parsed. */
36
+ export interface RecordTimestamps {
37
+ readonly createdAt: Date
38
+ readonly updatedAt: Date
39
+ }
40
+
41
+ /**
42
+ * Drops keys whose value is `undefined`.
43
+ *
44
+ * `PATCH` sends only what changed, and `null` clears a nullable field, so an
45
+ * `undefined` in a request body would be indistinguishable from "clear this"
46
+ * once `JSON.stringify` removed it. Building bodies through this makes the
47
+ * omission deliberate instead of incidental.
48
+ */
49
+ export function definedFields(fields: Record<string, unknown>): Record<string, unknown> {
50
+ return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined))
51
+ }