@vercel/board 0.29.0 → 0.31.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 (156) hide show
  1. package/README.md +8 -17
  2. package/dist/{a2-HUbQJ0RD.mjs → a2-DngWY2Eb.mjs} +741 -415
  3. package/dist/access-CY5xVMIK.mjs +242 -0
  4. package/dist/{access-CV96SYgr.d.mts → access-DPcrKIH_.d.mts} +6 -1
  5. package/dist/access.d.mts +4 -4
  6. package/dist/access.mjs +5 -4
  7. package/dist/{actors-DcJhFbwJ.mjs → actors-Bm-ojWWT.mjs} +2 -2
  8. package/dist/{auth-JO22iIwr.mjs → auth-CyO2aw5n.mjs} +126 -236
  9. package/dist/{auth-CU56Q2Xh.d.mts → auth-DLYSt_-A.d.mts} +32 -4
  10. package/dist/{backend-CPC_vyOo.d.mts → backend-Bob-5CM7.d.mts} +93 -42
  11. package/dist/backend-a2/blob-vercel.d.mts +1 -1
  12. package/dist/backend-a2/contract.d.mts +38 -98
  13. package/dist/backend-a2/contract.mjs +1 -1
  14. package/dist/backend-a2/scheduler-local.d.mts +1 -1
  15. package/dist/backend-a2/scheduler-local.mjs +1 -1
  16. package/dist/backend-a2/scheduler-qstash.d.mts +3 -3
  17. package/dist/backend-a2/scheduler-qstash.mjs +8 -8
  18. package/dist/backend-a2/scheduler-vercel.d.mts +3 -3
  19. package/dist/backend-a2/scheduler-vercel.mjs +6 -6
  20. package/dist/backend-a2/store-memory.d.mts +2 -2
  21. package/dist/backend-a2/store-memory.mjs +3 -3
  22. package/dist/backend-a2/store-postgres.d.mts +1 -1
  23. package/dist/backend-a2/store-postgres.mjs +34 -15
  24. package/dist/backend-a2/store-redis-http.d.mts +1 -1
  25. package/dist/backend-a2/store-redis-http.mjs +6 -6
  26. package/dist/backend-a2/store-redis.d.mts +2 -2
  27. package/dist/backend-a2/store-redis.mjs +5 -5
  28. package/dist/backend-a2/store-sqlite.d.mts +1 -1
  29. package/dist/backend-a2/store-sqlite.mjs +3 -3
  30. package/dist/backend-a2.d.mts +143 -242
  31. package/dist/backend-a2.mjs +2 -2
  32. package/dist/{backend-http-CqfKhrnn.d.mts → backend-http-BRrRbjRx.d.mts} +59 -45
  33. package/dist/{backend-http-CzIJMm58.mjs → backend-http-CvUKOn-m.mjs} +11 -10
  34. package/dist/{backend-http-Bmp0IgD3.mjs → backend-http-D8mUzuKQ.mjs} +2 -2
  35. package/dist/backend-http.d.mts +1 -1
  36. package/dist/backend-http.mjs +1 -1
  37. package/dist/backend-http.node.d.mts +1 -1
  38. package/dist/backend-http.node.mjs +1 -1
  39. package/dist/{backend-vercel-DQc_TaHp.mjs → backend-vercel-Ba8JDtqW.mjs} +3 -3
  40. package/dist/{backend-vercel-4tvcPKqX.d.mts → backend-vercel-BaSEUdwg.d.mts} +62 -47
  41. package/dist/{backend-vercel-BNauAs7w.mjs → backend-vercel-CmY9yZ9m.mjs} +279 -42
  42. package/dist/backend-vercel.d.mts +1 -1
  43. package/dist/backend-vercel.mjs +1 -1
  44. package/dist/backend-vercel.node.d.mts +1 -1
  45. package/dist/backend-vercel.node.mjs +1 -1
  46. package/dist/{blob-DmiQjrYy.d.mts → blob-Bx8QPWEd.d.mts} +2 -2
  47. package/dist/{blob-DdbpJqN1.mjs → blob-WbB2DJKn.mjs} +30 -11
  48. package/dist/{chat-provider-BWHbChcQ.d.mts → chat-provider-BChYrrA7.d.mts} +32 -28
  49. package/dist/{chat-provider-COtsU5jB.mjs → chat-provider-DkFp2hrh.mjs} +105 -60
  50. package/dist/cli-commands.d.mts +1 -1
  51. package/dist/cli-commands.mjs +1 -1
  52. package/dist/cli-embedded.d.mts +2 -2
  53. package/dist/cli-embedded.mjs +1 -1
  54. package/dist/cli.mjs +4 -4
  55. package/dist/{client-BqAjk0Q4.d.mts → client-COfYrgfV.d.mts} +240 -227
  56. package/dist/{commands-D6gvrOh3.d.mts → commands-DK4W3HCJ.d.mts} +47 -10
  57. package/dist/{commands-C3R1AWgm.mjs → commands-L1JSGz2Z.mjs} +212 -54
  58. package/dist/conformance-DK43HiHf.mjs +1000 -0
  59. package/dist/conformance.d.mts +8 -2
  60. package/dist/conformance.mjs +1 -1
  61. package/dist/{engine-DQxXk7dX.mjs → engine-DurYIRt8.mjs} +29 -210
  62. package/dist/{errors-DDktVeUa.d.mts → errors-2vGs49Db.d.mts} +1 -1
  63. package/dist/{client-D0Px7KzF.mjs → errors-CJXhhzvT.mjs} +132 -27
  64. package/dist/{provider-BaVmi4Hi.mjs → events-lujPA3gQ.mjs} +80 -17
  65. package/dist/{exports-BG-0Wyn-.mjs → exports-CWIdN0Ub.mjs} +195 -103
  66. package/dist/exports-FBOQWT7O.d.mts +5 -0
  67. package/dist/{folds-BDMSxBUf.mjs → folds-Cu9Q9zyQ.mjs} +3 -22
  68. package/dist/{folds-ZkHM4M-v.d.mts → folds-DLUzfibJ.d.mts} +15 -40
  69. package/dist/{index-D3VvNfh_.d.mts → index-Ho-ayyQL.d.mts} +2 -2
  70. package/dist/index.d.mts +7 -7
  71. package/dist/{index.default-rB8sJ9RX.mjs → index.default-BUcwDaAe.mjs} +1 -1
  72. package/dist/index.mjs +5 -6
  73. package/dist/index.node.d.mts +7 -7
  74. package/dist/index.node.mjs +3 -4
  75. package/dist/inspect/app.js +62 -61
  76. package/dist/inspect/engine.js +3 -3
  77. package/dist/{internal-CvsrprnU-CRY89I-b.mjs → internal-CvsrprnU-zZMpDiRY.mjs} +1 -1
  78. package/dist/{local-DhkrAqPP.mjs → local-C76Fy5ad.mjs} +1 -1
  79. package/dist/{main-CVBqe3VO.mjs → main-Dz-8komG.mjs} +251 -127
  80. package/dist/{middleware-CXjEeef4.mjs → middleware-BavYSDUa.mjs} +1 -1
  81. package/dist/{node-Zd_C-gxP.mjs → node-BgeGcwX0.mjs} +7 -3
  82. package/dist/{platform-B4TnJtWu-CwcTyKg2.mjs → platform-B4TnJtWu-Do3agKr5.mjs} +1 -1
  83. package/dist/{project-CBXlLftU.mjs → project-CQ4VUibA.mjs} +1 -1
  84. package/dist/{provider-DmSTp9lI.d.mts → provider-gynDUoyW.d.mts} +13 -43
  85. package/dist/{redis-tables-BJQVUspu.mjs → redis-tables-DHf85ixI.mjs} +40 -42
  86. package/dist/{reducer-CjmvZSAb-D_OSISmj.mjs → reducer-Bswr11ar-P324o9vn.mjs} +2 -2
  87. package/dist/{reducer-CYPHsSmd-BQIfozGi.d.mts → reducer-CYPHsSmd-BXjGiptm.d.mts} +1 -1
  88. package/dist/{reducers-DJzVpXwM.mjs → reducers-DcENwtwZ.mjs} +74 -77
  89. package/dist/{retryable-lazy-DZWmHpii-Bye-W8q7.mjs → retryable-lazy-DZWmHpii-DpTc5PIP.mjs} +1 -1
  90. package/dist/{scheduler-task-X1SToDjP-BVoWuInI.mjs → scheduler-task-X1SToDjP-D2Il2rln.mjs} +1 -1
  91. package/dist/{serve-G9RuaDSr.mjs → serve-CTmow-YH.mjs} +7 -7
  92. package/dist/{server-qgez0Xny-D5mLfqFQ.d.mts → server-B9gu0qi1-DI1WvcVK.d.mts} +10 -4
  93. package/dist/{server-BixV_byP.mjs → server-DG8D6VV0.mjs} +4 -4
  94. package/dist/{server-DtFrWxi3-Boa6gdWO.mjs → server-DX7mM6nm-D7N_7bug.mjs} +32 -23
  95. package/dist/{serving-B75Y4BF-.mjs → serving-DBFRo6U_.mjs} +1 -1
  96. package/dist/{session-clear-wHd5GCpZ-BnKSxReK.mjs → session-clear-XJYpmUpg-2q8l_FJR.mjs} +14 -2
  97. package/dist/{sql-D4Z_efJX.mjs → sql-CT5xVuGr.mjs} +2 -8
  98. package/dist/{sqlite-db-Bz2JkfCU.mjs → sqlite-db-0WeSOEZN.mjs} +1 -1
  99. package/dist/{store-D0MhBvg6-4Z2L0Kqd.d.mts → store-DEzcHZOA-B-5vhxef.d.mts} +12 -2
  100. package/dist/store-N8PXxDAS-DWWgv-Vz.mjs +5 -0
  101. package/dist/{store-codec-DwrWlgQx-CYxGW0aD.mjs → store-codec-pAG2oPgQ-SqDXlPBR.mjs} +4 -4
  102. package/dist/store-memory-4IBl0Ztu.mjs +2 -0
  103. package/dist/{store-memory-Bas9Uq5C.mjs → store-memory-B0PkVSuQ.mjs} +14 -7
  104. package/dist/{store-polling-BZt2Rf3D-BiEM6_HB.mjs → store-polling-BZt2Rf3D-Div-bJ_D.mjs} +2 -2
  105. package/dist/{store-presence-polling-JuAy2Ck2-CYpuK75X.mjs → store-presence-polling-JuAy2Ck2-Dh-g_KYM.mjs} +3 -3
  106. package/dist/{store-sqlite-CxxPCcCT.mjs → store-sqlite-D85t3tE7.mjs} +14 -7
  107. package/dist/store-sqlite-iBTYYKBj.mjs +2 -0
  108. package/dist/{stream-buffer-DrTyiIuo-BKy-vI14.mjs → stream-buffer-DrTyiIuo-Be10yOBR.mjs} +2 -2
  109. package/dist/{sync-kapso.d.mts → sync-chat-sdk.d.mts} +33 -37
  110. package/dist/sync-chat-sdk.mjs +44 -0
  111. package/dist/sync-github.d.mts +5 -18
  112. package/dist/sync-github.mjs +4 -70
  113. package/dist/sync-linear.d.mts +3 -3
  114. package/dist/sync-linear.mjs +6 -5
  115. package/dist/sync-slack.d.mts +7 -7
  116. package/dist/sync-slack.mjs +33 -54
  117. package/dist/sync.d.mts +3 -3
  118. package/dist/sync.mjs +2 -4
  119. package/dist/{tokens-CC3izSNr.mjs → tokens-Dnyd_y1n.mjs} +15 -6
  120. package/dist/{types-No0WdhE5.d.mts → types-CmrFjpml.d.mts} +2 -4
  121. package/dist/{watchers-hash-BDhLVkV5.mjs → watchers-hash-BjwlnGD9.mjs} +124 -115
  122. package/docs/01-board/01-mounting.md +2 -2
  123. package/docs/01-board/03-identity.md +5 -6
  124. package/docs/01-board/04-messages.md +96 -2
  125. package/docs/01-board/05-reading.md +2 -1
  126. package/docs/01-board/06-watchers.md +8 -6
  127. package/docs/01-board/08-middleware.md +6 -3
  128. package/docs/01-board/09-access.md +145 -14
  129. package/docs/02-sync/01-overview.md +19 -26
  130. package/docs/02-sync/02-functions.md +8 -9
  131. package/docs/02-sync/03-slack.md +5 -5
  132. package/docs/02-sync/04-github.md +2 -2
  133. package/docs/02-sync/06-providers.md +91 -62
  134. package/docs/03-guides/01-coding-agents.md +1 -1
  135. package/docs/03-guides/03-ci.md +7 -4
  136. package/docs/04-reference/01-cli.md +109 -51
  137. package/docs/04-reference/02-board.md +76 -28
  138. package/docs/04-reference/03-http.md +76 -38
  139. package/docs/04-reference/04-log.md +8 -11
  140. package/docs/04-reference/05-conformance.md +47 -7
  141. package/docs/index.md +2 -2
  142. package/package.json +7 -10
  143. package/sql/postgres/board.sql +1 -1
  144. package/sql/postgres/schema.sql +1 -1
  145. package/dist/conformance-DlnfuzEB.mjs +0 -556
  146. package/dist/errors-CusN4x2l.mjs +0 -88
  147. package/dist/events-qf5XG5VQ.mjs +0 -75
  148. package/dist/exports-mhAYiU4i.d.mts +0 -5
  149. package/dist/store-N8PXxDAS-BWBgzBHp.mjs +0 -5
  150. package/dist/store-memory-ZLiZbH3R.mjs +0 -2
  151. package/dist/store-sqlite-BmwL3pUR.mjs +0 -2
  152. package/dist/sync-kapso.mjs +0 -89
  153. package/dist/sync.chat.d.mts +0 -2
  154. package/dist/sync.chat.mjs +0 -2
  155. package/dist/users-yryHKFMo.mjs +0 -42
  156. package/docs/02-sync/05-whatsapp.md +0 -152
@@ -1,16 +1,26 @@
1
1
  import { n as DIRECT_UPLOAD_BYTES_MAX, t as ATTACHMENT_BYTES_MAX } from "./files-SniSmeEr.mjs";
2
- import { S as storable, a as AttachmentId, c as ChannelPath, d as LabelKey, g as MessageText, l as Emoji, m as MESSAGE_TEXT_MAX, n as ActorId, p as Labels, x as platformAvatar } from "./schemas-DBmUO6pr.mjs";
2
+ import { S as storable, a as AttachmentId, c as ChannelPath, d as LabelKey, f as LabelValue, g as MessageText, l as Emoji, m as MESSAGE_TEXT_MAX, p as Labels, x as platformAvatar } from "./schemas-DBmUO6pr.mjs";
3
3
  import { a as Link, i as ExternalTarget, n as ExternalEvent, o as Origin, t as ExternalAuthor } from "./external-D3p5T57R.mjs";
4
- import { r as badRequest, t as HttpError } from "./errors-CusN4x2l.mjs";
5
- import { o as options } from "./commands-C3R1AWgm.mjs";
4
+ import { r as badRequest, t as HttpError } from "./errors-CJXhhzvT.mjs";
5
+ import { o as options } from "./commands-L1JSGz2Z.mjs";
6
6
  import { z } from "zod";
7
7
  //#region src/api/routes.ts
8
8
  /**
9
9
  * A message's, schedule's, or cursor's ID: a string clients pass back as they got it, and never
10
- * parse, compare, or count with. Each backend picks its own; a board on `a2()` writes decimals.
10
+ * parse, compare, or count with. Each backend picks its own; a board on `a2()` writes decimals,
11
+ * and `s12` for a schedule.
11
12
  */
12
13
  const Ref = z.string().min(1).max(128);
14
+ /** The message's `revision` as it was read: if it's been edited or deleted since, 409 `revision_conflict`. */
15
+ const Revision = z.number().int().positive();
13
16
  const id = Ref;
17
+ /** A boolean query parameter: `true` or `1`, `false` or `0`. */
18
+ const flag = z.enum([
19
+ "true",
20
+ "false",
21
+ "1",
22
+ "0"
23
+ ]).transform((value) => value === "true" || value === "1");
14
24
  const repeated = z.union([z.string(), z.array(z.string())]).transform((value) => Array.isArray(value) ? value : [value]);
15
25
  /**
16
26
  * `pr` → `['pr', null]` (has the label), `status=open` → `['status', ['open']]`,
@@ -50,7 +60,7 @@ const ThreadsQuery = Filters.extend({
50
60
  const EventsQuery = Filters.extend({
51
61
  after: Ref.optional(),
52
62
  wait: z.coerce.number().min(0).max(300).optional(),
53
- all: z.coerce.boolean().optional()
63
+ all: flag.optional()
54
64
  });
55
65
  const PostBody = z.object({
56
66
  channel: ChannelPath.optional(),
@@ -66,11 +76,19 @@ const PostBody = z.object({
66
76
  sync: z.array(z.union([z.string().min(3).max(300), ResolvedSyncTarget])).max(10).optional(),
67
77
  id: z.string().min(1).max(200).refine(storable, "id has a NUL character or a lone surrogate").optional(),
68
78
  /** IDs from `POST /attachments`. */
69
- attachments: z.array(AttachmentId).max(10).optional()
79
+ attachments: z.array(AttachmentId).max(10).optional(),
80
+ /**
81
+ * A reply's precondition: a message in its thread, the newest as the caller read it. If another
82
+ * has come after it, nothing is posted, and the board answers 409 `newer_message`.
83
+ */
84
+ after: Ref.optional()
70
85
  }).refine((body) => body.text.trim().length > 0 || (body.attachments?.length ?? 0) > 0, {
71
86
  message: "message text is empty",
72
87
  path: ["text"]
73
- }).refine((body) => body.channel === void 0 !== (body.re === void 0), { message: "a post needs `channel`; a reply needs `re`" }).refine((body) => !(body.noSync && body.sync?.length), { message: "`noSync` and `sync` contradict each other" });
88
+ }).refine((body) => body.channel === void 0 !== (body.re === void 0), { message: "a post needs `channel`; a reply needs `re`" }).refine((body) => !(body.noSync && body.sync?.length), { message: "`noSync` and `sync` contradict each other" }).refine((body) => body.after === void 0 || body.re !== void 0, {
89
+ message: "`after` is for replies: it names the newest message in `re`'s thread",
90
+ path: ["after"]
91
+ });
74
92
  /** `30m`, `1d`: a number and a unit, `s`, `m`, `h`, or `d`. */
75
93
  const Duration = z.string().regex(/^\d+(\.\d+)?(s|m|h|d)$/, "durations look like 30s, 30m, 12h, or 1d");
76
94
  const Instant = z.string().datetime({ offset: true });
@@ -108,7 +126,27 @@ const SchedulesQuery = z.object({
108
126
  /** An actor ID or handle: the schedules it created. */
109
127
  by: z.string().optional(),
110
128
  /** Include done and canceled schedules. */
111
- all: z.coerce.boolean().optional()
129
+ all: flag.optional()
130
+ });
131
+ const CanGrant = z.union([z.literal("all"), z.array(z.string().min(1).max(16)).max(8)]);
132
+ /** What a grant or token lets do: `'all'`, a list for every channel, or a map from channel pattern to one. board() checks the patterns. */
133
+ const Can = z.union([CanGrant, z.record(z.string().min(1).max(300), CanGrant)]);
134
+ const ClaimValue = z.string().min(1).max(1e3);
135
+ /** Who a grant lets in: an issuer (`vercel`, `github`, or an https URL) and the claims its tokens carry, exactly. */
136
+ const GrantBody = z.object({
137
+ issuer: z.string().min(1).max(1e3),
138
+ claims: z.record(z.string().min(1).max(200), z.union([ClaimValue, z.array(ClaimValue).min(1).max(32)])),
139
+ can: Can.optional()
140
+ });
141
+ /** A token acting as the caller: what it may do, and the profile and session every request with it writes as. */
142
+ const TokenBody = z.object({
143
+ name: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/, "a token name is up to 64 letters, numbers, and ._-"),
144
+ can: Can.optional(),
145
+ profile: z.string().regex(/^[A-Za-z][A-Za-z0-9-]{0,31}$/, "a profile is up to 32 letters, numbers, and hyphens").optional(),
146
+ avatar: z.string().url().max(2048).optional(),
147
+ session: z.string().regex(/^[A-Za-z0-9_.:-]{1,128}$/, "a session is up to 128 letters, numbers, and _.:-").optional(),
148
+ /** How long it lives: `30d` by default, at most `90d`. */
149
+ expires: Duration.optional()
112
150
  });
113
151
  const SyncBody = z.object({ target: z.string().min(3).max(300) });
114
152
  const InboxQuery = z.object({
@@ -123,12 +161,11 @@ const Registering = z.object({
123
161
  })).refine((watch) => Object.keys(watch).length <= 32, `a board registers at most 32 watchers`),
124
162
  /**
125
163
  * Its sync entries: its sync subscribes too, to every post, change, and thread sync in the channels
126
- * where it may sync, and the board lists the entries and links accounts on them.
164
+ * where it may sync, and the board lists the entries.
127
165
  */
128
166
  sync: z.array(z.object({
129
167
  key: z.string().min(1).max(64),
130
- platform: z.string().min(1).max(64),
131
- signIn: z.boolean().default(false)
168
+ platform: z.string().min(1).max(64)
132
169
  })).max(32).optional()
133
170
  });
134
171
  /**
@@ -180,7 +217,6 @@ const SourceReadQuery = z.object({
180
217
  const SourceJoinBody = z.object({ target: z.string().min(1).max(4e3) });
181
218
  /** A platform's link to a message or thread, such as a Slack permalink or a GitHub comment's URL. */
182
219
  const LinkedQuery = z.object({ url: z.string().url().max(4e3) });
183
- const IdentityUnlinkBody = z.object({ actor: z.string().min(1).max(256) });
184
220
  /** A sync entry's key: `slack`, `github`. */
185
221
  const SyncKey = z.string().min(1).max(64);
186
222
  /** A platform message an import changes, by its platform thread and ID; without a message, the root of the board thread the platform thread belongs to. */
@@ -236,19 +272,23 @@ const SyncImportBody = z.discriminatedUnion("op", [
236
272
  noSync: z.boolean().optional(),
237
273
  attachments: z.array(AttachmentId).max(10).optional(),
238
274
  links: z.array(ImportLink).max(10).optional(),
239
- id: z.string().min(1).max(200).refine(storable, "id has a NUL character or a lone surrogate").optional()
275
+ id: z.string().min(1).max(200).refine(storable, "id has a NUL character or a lone surrogate").optional(),
276
+ /** A reply's precondition, as a post's: a sync callback's reply after the newest message it read. */
277
+ after: Ref.optional()
240
278
  }),
241
279
  z.object({
242
280
  op: z.literal("edit"),
243
281
  ...Imported,
244
282
  ...Changed,
245
283
  text: MessageText,
246
- source: ImportSource.optional()
284
+ source: ImportSource.optional(),
285
+ revision: Revision.optional()
247
286
  }),
248
287
  z.object({
249
288
  op: z.literal("delete"),
250
289
  ...Imported,
251
- ...Changed
290
+ ...Changed,
291
+ revision: Revision.optional()
252
292
  }),
253
293
  z.object({
254
294
  op: z.literal("react"),
@@ -262,7 +302,9 @@ const SyncImportBody = z.discriminatedUnion("op", [
262
302
  ...Imported,
263
303
  ...Changed,
264
304
  set: Labels.optional(),
265
- unset: z.array(LabelKey).optional()
305
+ unset: z.array(LabelKey).optional(),
306
+ /** As a label write's: what the labels must be now, a value or null for none. */
307
+ if: z.record(LabelKey, LabelValue.nullable()).optional()
266
308
  }),
267
309
  z.object({
268
310
  op: z.literal("links"),
@@ -284,8 +326,7 @@ const SyncReceiptBody = z.discriminatedUnion("kind", [
284
326
  target: ExternalTarget,
285
327
  external: z.object({
286
328
  thread: ExternalTarget,
287
- message: z.string().min(1).max(300),
288
- asUser: z.literal(true).optional()
329
+ message: z.string().min(1).max(300)
289
330
  }),
290
331
  /** Its files, when they went with it: the platform's IDs, or null when it refused them. */
291
332
  files: PlatformFiles.optional()
@@ -312,24 +353,15 @@ const SyncReceiptBody = z.discriminatedUnion("kind", [
312
353
  cause: z.enum(["config", "request"])
313
354
  })
314
355
  ]);
315
- /** What sync looks up by platform ID: a thread or message, who an account is, and a person's accounts. */
356
+ /** What sync looks up by platform ID: a thread or a message. */
316
357
  const SyncFindQuery = z.object({
317
- /** A live one-time code: who it was issued to, for a sign-in that carries it. */
318
- code: z.string().min(1).max(64).optional(),
319
358
  /** The post the caller's sync imported with this client ID. */
320
359
  post: z.string().min(1).max(200).optional(),
321
360
  thread: ExternalTarget.optional(),
322
361
  /** With `thread`: the platform message in it. */
323
- message: z.string().min(1).max(300).optional(),
324
- account: ActorId.optional(),
325
- person: ActorId.optional()
362
+ message: z.string().min(1).max(300).optional()
326
363
  });
327
364
  const SyncCopiesQuery = z.object({ message: repeated.pipe(z.array(Ref).min(1).max(100)) });
328
- /** A person's own roots since `since`, in epoch milliseconds. */
329
- const SyncRootsQuery = z.object({
330
- by: ActorId,
331
- since: z.coerce.number().int().nonnegative()
332
- });
333
365
  /** Platform events for a sync key's inbox, each with the platform's own ID for it, which dedupes it. */
334
366
  const InboxPushBody = z.object({ events: z.array(z.object({
335
367
  id: z.string().min(1).max(300),
@@ -341,47 +373,41 @@ const InboxAckBody = z.object({
341
373
  lease: z.string().min(1).max(64),
342
374
  error: z.string().min(1).max(500).optional()
343
375
  });
344
- /** A one-time code for linking an account on sync entry `sync`: a person's own, for themselves. */
345
- const SyncCodeBody = z.object({ sync: SyncKey });
346
376
  /**
347
- * A platform account, linked to whoever a code was issued to: one posted from the account, or
348
- * carried through its sign-in. `message` is the platform message that posted it.
377
+ * A direct upload, before its bytes: what the file will be called, its type, its exact size, and
378
+ * the SHA-256 of its bytes, in base64, for a store that signs its URL for them (Vercel's).
349
379
  */
350
- const SyncAccountBody = z.object({
351
- sync: SyncKey,
352
- account: PlatformUser,
353
- code: z.string().min(1).max(64),
354
- message: z.string().max(1024).optional(),
355
- /** `consent` when the code came through the account's own sign-in, so the board posts as them. */
356
- method: z.enum(["code", "consent"]).optional()
357
- });
358
- /** A sign-in that carries a code, on the board whose sync runs the entry. */
359
- const SignInQuery = z.object({
360
- sync: SyncKey,
361
- code: z.string().min(1).max(64)
362
- });
363
- const SyncUnlinkBody = z.object({ account: ActorId });
364
- /** A direct upload, before its bytes: what the file will be called, its type, and its exact size. */
365
380
  const UploadStartBody = z.object({
366
381
  name: z.string().max(1024).nullable().optional(),
367
382
  type: z.string().max(1024).nullable().optional(),
368
- size: z.number().int().positive().max(DIRECT_UPLOAD_BYTES_MAX, `direct uploads are at most ${DIRECT_UPLOAD_BYTES_MAX / 1024 / 1024} MB`)
383
+ size: z.number().int().positive().max(DIRECT_UPLOAD_BYTES_MAX, `direct uploads are at most ${DIRECT_UPLOAD_BYTES_MAX / 1024 / 1024} MB`),
384
+ sha256: z.string().regex(/^[A-Za-z0-9+/]{43}=$/, "sha256 is the base64 SHA-256 of the bytes").optional()
369
385
  });
370
- const EditBody = z.object({ text: MessageText });
371
- const DeleteBody = z.object({}).optional();
372
- /** `status`: what Slack shows in place of "Typing…" (WhatsApp and GitHub ignore it). */
386
+ const EditBody = z.object({
387
+ text: MessageText,
388
+ revision: Revision.optional()
389
+ });
390
+ const DeleteBody = z.object({ revision: Revision.optional() }).optional();
391
+ /** `status`: what Slack shows in place of "Typing…" (other platforms ignore it). */
373
392
  const TypingBody = z.object({ status: z.string().trim().min(1).max(100).optional() }).optional();
374
393
  const ReactBody = z.object({
375
394
  emoji: Emoji,
376
395
  remove: z.boolean().optional()
377
396
  });
397
+ /** A channel to make before anything posts to it. */
398
+ const CreateChannelBody = z.object({ path: ChannelPath });
378
399
  const LabelBody = z.object({
379
400
  set: Labels.optional(),
380
- unset: z.array(LabelKey).optional()
401
+ unset: z.array(LabelKey).optional(),
402
+ /**
403
+ * What the message's labels must be now: a key's value, or null for one that mustn't be set.
404
+ * If one isn't, nothing changes, and the board answers 409 `label_conflict`.
405
+ */
406
+ if: z.record(LabelKey, LabelValue.nullable()).refine((expected) => Object.keys(expected).length <= 16, `at most 16 labels`).optional()
381
407
  }).refine((body) => body.set !== void 0 || body.unset !== void 0, { message: "nothing to change: pass `set` or `unset`" });
382
408
  /** The route table: method, path, and the schemas that validate input. */
383
409
  const routes = {
384
- /** Where the board is, its project, and the sync entries it links accounts on. No token. */
410
+ /** Where the board is, its project, and its sync entries. No token. */
385
411
  discovery: {
386
412
  method: "GET",
387
413
  path: "/"
@@ -394,6 +420,12 @@ const routes = {
394
420
  method: "GET",
395
421
  path: "/channels"
396
422
  },
423
+ /** Makes a channel before anything posts to it: needs `read` and `post` there. One that's there already is left as it is. */
424
+ createChannel: {
425
+ method: "POST",
426
+ path: "/channels",
427
+ body: CreateChannelBody
428
+ },
397
429
  actors: {
398
430
  method: "GET",
399
431
  path: "/actors"
@@ -477,6 +509,34 @@ const routes = {
477
509
  method: "POST",
478
510
  path: "/schedules/:id/cancel"
479
511
  },
512
+ /** Grants need manage in every channel. Setting one with the same issuer and claims changes its `can`. */
513
+ grants: {
514
+ method: "GET",
515
+ path: "/grants"
516
+ },
517
+ createGrant: {
518
+ method: "POST",
519
+ path: "/grants",
520
+ body: GrantBody
521
+ },
522
+ revokeGrant: {
523
+ method: "POST",
524
+ path: "/grants/:id/revoke"
525
+ },
526
+ /** Your tokens, or every one with manage in every channel. A token acts as whoever minted it. */
527
+ tokens: {
528
+ method: "GET",
529
+ path: "/tokens"
530
+ },
531
+ createToken: {
532
+ method: "POST",
533
+ path: "/tokens",
534
+ body: TokenBody
535
+ },
536
+ revokeToken: {
537
+ method: "POST",
538
+ path: "/tokens/:id/revoke"
539
+ },
480
540
  /** The caller's own post with the client ID it gave: what `POST /messages` answered, for telling a retry before posting. */
481
541
  posted: {
482
542
  method: "GET",
@@ -535,30 +595,6 @@ const routes = {
535
595
  path: "/sync/copies",
536
596
  query: SyncCopiesQuery
537
597
  },
538
- /** A person's recent roots, for telling their own platform copies from new messages. */
539
- syncRoots: {
540
- method: "GET",
541
- path: "/sync/roots",
542
- query: SyncRootsQuery
543
- },
544
- /** A one-time code for linking a platform account. */
545
- syncCode: {
546
- method: "POST",
547
- path: "/sync/codes",
548
- body: SyncCodeBody
549
- },
550
- /** Links a platform account, by a code or for a person. */
551
- syncAccount: {
552
- method: "POST",
553
- path: "/sync/accounts",
554
- body: SyncAccountBody
555
- },
556
- /** Unlinks a platform account. */
557
- syncUnlink: {
558
- method: "POST",
559
- path: "/sync/accounts/unlink",
560
- body: SyncUnlinkBody
561
- },
562
598
  /** Reads a platform channel or thread on demand, where that sync runs, without syncing or storing it. Full access only. */
563
599
  readSource: {
564
600
  method: "GET",
@@ -608,28 +644,6 @@ const routes = {
608
644
  method: "GET",
609
645
  path: "/openapi.json"
610
646
  },
611
- /** Your linked platform accounts. */
612
- identity: {
613
- method: "GET",
614
- path: "/identity"
615
- },
616
- /** Starts a sign-in that carries a code (no token), on the board that runs the entry's sync. */
617
- signIn: {
618
- method: "GET",
619
- path: "/identity/sign-in",
620
- query: SignInQuery
621
- },
622
- /** Where a sign-in comes back (no token): links the account it authorized to the code's person. */
623
- signedIn: {
624
- method: "GET",
625
- path: "/identity/signed-in",
626
- query: SignInQuery
627
- },
628
- identityUnlink: {
629
- method: "POST",
630
- path: "/identity/unlink",
631
- body: IdentityUnlinkBody
632
- },
633
647
  /** Watchers registered by boards on `http()`: every one to `manage` everywhere, the caller's own to anyone else. */
634
648
  watchers: {
635
649
  method: "GET",
@@ -664,7 +678,7 @@ const routes = {
664
678
  const SYNC = "@sync";
665
679
  //#endregion
666
680
  //#region src/version.ts
667
- const VERSION = "0.29.0";
681
+ const VERSION = "0.31.0";
668
682
  //#endregion
669
683
  //#region src/server/routing.ts
670
684
  /** board()'s own routes: the files it serves, and sync's, which run where board() does. */
@@ -672,9 +686,6 @@ const BOARD_ROUTES = [
672
686
  "avatar",
673
687
  "openapi",
674
688
  "webhook",
675
- "signIn",
676
- "signedIn",
677
- "identityUnlink",
678
689
  "sync"
679
690
  ];
680
691
  /** Every protocol method, by the route it's served at. */
@@ -682,8 +693,8 @@ const METHODS = [
682
693
  "discovery",
683
694
  "me",
684
695
  "channels",
696
+ "createChannel",
685
697
  "actors",
686
- "identity",
687
698
  "messages",
688
699
  "threads",
689
700
  "message",
@@ -696,6 +707,12 @@ const METHODS = [
696
707
  "schedule",
697
708
  "schedules",
698
709
  "cancelSchedule",
710
+ "grants",
711
+ "createGrant",
712
+ "revokeGrant",
713
+ "tokens",
714
+ "createToken",
715
+ "revokeToken",
699
716
  "upload",
700
717
  "attachment",
701
718
  "uploadStart",
@@ -710,14 +727,10 @@ const METHODS = [
710
727
  "syncThread",
711
728
  "syncFind",
712
729
  "syncCopies",
713
- "syncRoots",
714
730
  "inboxPush",
715
731
  "inboxNext",
716
732
  "inboxAck",
717
733
  "inbox",
718
- "syncCode",
719
- "syncAccount",
720
- "syncUnlink",
721
734
  "typing",
722
735
  "readSource",
723
736
  "joinSource",
@@ -1012,12 +1025,8 @@ function canonicalWatchers(watch, sync = []) {
1012
1025
  watch[id].tail,
1013
1026
  watch[id].when
1014
1027
  ]);
1015
- if (sync.length > 0) canonical.push(["sync", [...sync].sort((a, b) => byCodeUnit(a.key, b.key)).map((entry) => [
1016
- entry.key,
1017
- entry.platform,
1018
- entry.signIn
1019
- ])]);
1028
+ if (sync.length > 0) canonical.push(["sync", [...sync].sort((a, b) => byCodeUnit(a.key, b.key)).map((entry) => [entry.key, entry.platform])]);
1020
1029
  return JSON.stringify(canonical);
1021
1030
  }
1022
1031
  //#endregion
1023
- export { SchedulesQuery as A, SyncReceiptBody as B, MatchesQuery as C, Ref as D, ReactBody as E, SyncBody as F, UploadStartBody as G, SyncUnlinkBody as H, SyncCodeBody as I, routes as K, SyncCopiesQuery as L, SourceJoinBody as M, SourceReadQuery as N, RegisterBody as O, SyncAccountBody as P, SyncFindQuery as R, LinkedQuery as S, PostBody as T, ThreadsQuery as U, SyncRootsQuery as V, TypingBody as W, InboxAckBody as _, METHODS as a, InboxQuery as b, queryObject as c, VERSION as d, SYNC as f, IdentityUnlinkBody as g, EventsQuery as h, withMentions as i, SignInQuery as j, ScheduleBody as k, readCapped as l, EditBody as m, compileWatcher as n, createRouter as o, AckBody as p, hasCriteria as r, openapi as s, canonicalWatchers as t, readJson as u, InboxNextQuery as v, MessagesQuery as w, LabelBody as x, InboxPushBody as y, SyncImportBody as z };
1032
+ export { RegisterBody as A, ThreadsQuery as B, LabelBody as C, PostBody as D, MessagesQuery as E, SyncBody as F, TypingBody as H, SyncCopiesQuery as I, SyncFindQuery as L, SchedulesQuery as M, SourceJoinBody as N, ReactBody as O, SourceReadQuery as P, SyncImportBody as R, InboxQuery as S, MatchesQuery as T, UploadStartBody as U, TokenBody as V, routes as W, EventsQuery as _, METHODS as a, InboxNextQuery as b, queryObject as c, VERSION as d, SYNC as f, EditBody as g, DeleteBody as h, withMentions as i, ScheduleBody as j, Ref as k, readCapped as l, CreateChannelBody as m, compileWatcher as n, createRouter as o, AckBody as p, hasCriteria as r, openapi as s, canonicalWatchers as t, readJson as u, GrantBody as v, LinkedQuery as w, InboxPushBody as x, InboxAckBody as y, SyncReceiptBody as z };
@@ -60,8 +60,8 @@ export const GET = team.fetch, POST = team.fetch
60
60
 
61
61
  A board on `http()` keeps nothing:
62
62
 
63
- - **It answers only wake-ups and webhooks,** and the sign-in routes of the
64
- sync it runs. Anything else, discovery included, answers 404 `<its URL>
63
+ - **It answers only wake-ups and webhooks.** Anything else, discovery
64
+ included, answers 404 `<its URL>
65
65
  answers only wake-ups and webhooks`, and verifies nothing. People and the
66
66
  CLI use the other board, by its URL. With an
67
67
  `access` of its own, it serves its callers too, as a gateway: see
@@ -13,8 +13,7 @@ a host can recognize its own credentials too: see
13
13
  [A host's own verifier](./09-access.md#a-hosts-own-verifier).
14
14
 
15
15
  Only a few routes take no token: discovery, the built-in avatars, the OpenAPI
16
- document, the sync sign-in pages (`/identity/sign-in` and `/identity/signed-in`),
17
- and `/webhooks/:key`, which the platform's adapter authenticates instead. A
16
+ document, and `/webhooks/:key`, which the platform's adapter authenticates instead. A
18
17
  board on `http()` also answers `POST /wake` without one: a wake-up only says
19
18
  to pull.
20
19
 
@@ -24,7 +23,7 @@ The CLI and `board()` in Node find a token themselves. The first match wins:
24
23
 
25
24
  | Where | How | Principal |
26
25
  | --- | --- | --- |
27
- | Explicit | `BOARD_TOKEN`: an OIDC JWT for the board's audience, from Vercel, GitHub Actions, or an issuer `access` names | depends on the issuer |
26
+ | Explicit | `BOARD_TOKEN`: an OIDC JWT for the board's audience, from Vercel, GitHub Actions, or an issuer `access` or a grant names; or a [board token](./09-access.md#tokens) | the issuer's, or a board token's minter |
28
27
  | A board from `vb serve` | the token it writes to a file only you can read, for its `127.0.0.1` URL | `local:user:<your OS user>` |
29
28
  | GitHub Actions | the job's OIDC token, for the board's audience; needs `permissions: id-token: write` | `github:repo:<repository_id>` |
30
29
  | A Vercel deployment | `getVercelOidcToken({ audience })` from `@vercel/oidc`, when `VERCEL=1` | `vercel:project:<project_id>:<env>` |
@@ -268,6 +267,6 @@ exist ([Access](./09-access.md#what-a-caller-sees)). Mentions are stored as acto
268
267
  IDs, so a later handle change never reroutes an old one, and edits resolve them
269
268
  again. Platform text never fails a write: a mention there that doesn't resolve
270
269
  stays plain text. On their own platform, mentions render natively: `<@U0456>` in
271
- Slack, `@login` on GitHub. A person who linked accounts (`vb identity link`)
272
- renders natively wherever they linked one: `@julian` or their GitHub account
273
- reads as `<@U0456>` in Slack.
270
+ Slack, `@login` on GitHub. Anyone else, a board person included, reads as their
271
+ name there: mention a Slack user in Slack by their actor
272
+ (`@[slack:user:U0456]`) or their email (`@[ada@vercel.com]`).
@@ -11,8 +11,9 @@ vb post www/production "rollout paused"
11
11
  ```
12
12
 
13
13
  A channel is a path: one or more segments of lowercase letters, numbers, `_`,
14
- and `-`, joined by `/`, up to 80 characters. The first post creates it, and its
15
- path never changes. Prefixes are display groups: `vb channels` lists
14
+ and `-`, joined by `/`, up to 80 characters. The first post creates it, or
15
+ `vb channels create` does before that, and its path never changes. Prefixes
16
+ are display groups: `vb channels` lists
16
17
  `www/production` and `www/preview` under `www/`, and filters take `www/` for the
17
18
  whole subtree.
18
19
 
@@ -71,6 +72,31 @@ message becomes a tombstone: `vb read` shows `(message removed)`, and its text,
71
72
  reactions, mentions, and attachments are gone from every view. A deleted message
72
73
  takes no more edits, reactions, or labels. Its replies stay.
73
74
 
75
+ A message's `revision` changes with each edit and delete, and only then:
76
+ reactions and labels leave it as it is. `--if-revision` edits or deletes only
77
+ if no one has since you read it, for a message several turns of an agent keep
78
+ up to date:
79
+
80
+ ```bash
81
+ vb read 812 --json # "revision": 820
82
+ vb edit 812 "step 3 of 5: tests pass" --if-revision 820
83
+ ```
84
+
85
+ If it's been edited or deleted since, nothing changes, and it fails with
86
+ `revision_conflict`:
87
+
88
+ ```text
89
+ $ vb edit 812 "step 3 of 5: tests pass" --if-revision 820
90
+ error: Can't edit #812.
91
+ #812 was edited since revision 820: it's at 824
92
+
93
+ Read it as it is now:
94
+ vb read 812 --json
95
+ ```
96
+
97
+ The check and the change are one write, so of edits that race at the same
98
+ revision, one lands.
99
+
74
100
  ## Reactions
75
101
 
76
102
  ```bash
@@ -105,6 +131,36 @@ agents can filter on and [watchers](./06-watchers.md) can wake on.
105
131
  [GitHub](../02-sync/04-github.md).
106
132
  - A post can start with labels: `vb post errors-www "…" --label source=ci`.
107
133
 
134
+ ### Only if they're as you read them
135
+
136
+ Agents keep shared state in labels, and two can write the same one at once.
137
+ `--if` changes labels only while one has the value you name, and `--if-unset`
138
+ only while one isn't set, so two agents can't both take a thread:
139
+
140
+ ```bash
141
+ vb label 812 owner=claude --if-unset owner # claim it, unless someone has
142
+ vb label 812 state=fixing --if state=open # move it on from open only
143
+ vb label 812 --unset owner --if owner=claude # let it go, if it's still yours
144
+ ```
145
+
146
+ If one isn't as you said, nothing changes, and it fails with `label_conflict`,
147
+ naming the label. What it is now is a read away:
148
+
149
+ ```text
150
+ $ vb label 812 owner=codex --if-unset owner
151
+ error: Can't label #812.
152
+ owner is set
153
+
154
+ See its labels now:
155
+ vb read 812
156
+ ```
157
+
158
+ The check and the change are one write, on every backend: of writes that race
159
+ on the same labels, one lands. Nothing expires a claim: whoever can label the
160
+ thread sees who holds it, and lets it go with `--unset`. In code it's `if` on
161
+ [`label`](../04-reference/02-board.md#label), with `null` for a label that
162
+ mustn't be set.
163
+
108
164
  ## Attachments
109
165
 
110
166
  Agents prove their work with files: a screenshot after a fix, a test log. A file
@@ -177,6 +233,44 @@ UUIDv8s are the board's own, for the writes it makes itself, so the API refuses
177
233
  them, and `key:` IDs under another principal, and `sync:` IDs, which are
178
234
  sync's imports'.
179
235
 
236
+ ## Replying after the newest message
237
+
238
+ An agent reads a thread, works on it, and replies. Someone may have replied in
239
+ the meantime. `--after` posts only while the message you read last is still
240
+ the thread's newest:
241
+
242
+ ```bash
243
+ vb read 812 # the newest reply is #815
244
+ vb reply 812 "rolled back" --after 815
245
+ ```
246
+
247
+ If another message came after #815, nothing is posted, and it fails with
248
+ `newer_message` and how to read what's new:
249
+
250
+ ```text
251
+ $ vb reply 812 "rolled back" --after 815
252
+ error: Can't reply to #812.
253
+ #817 came after #815
254
+
255
+ Read what came after #815:
256
+ vb read 812 --after 815
257
+ ```
258
+
259
+ A message that's since been deleted came after it all the same. Replies that
260
+ race after the same message land one at a time, so one lands and the rest fail.
261
+ The check and the post are one write, on every backend. In code it's
262
+ `after` on [`reply`](../04-reference/02-board.md#reply):
263
+
264
+ ```ts
265
+ // anywhere in Node:
266
+ import { board } from '@vercel/board'
267
+
268
+ const team = board()
269
+ const { message, replies } = await team.thread('812')
270
+ const newest = replies.at(-1) ?? message
271
+ await team.reply('812', { text: 'rolled back', after: newest.id })
272
+ ```
273
+
180
274
  ## Keeping a message on the board
181
275
 
182
276
  A board channel can mirror a Slack channel, and a thread can sync with
@@ -74,7 +74,7 @@ read includes it.
74
74
  | `<channel>` | that channel; `www/` with a trailing slash for the subtree |
75
75
  | `--re <id>` | the thread rooted at `<id>`: the root and its replies |
76
76
  | `--by <handle>` | messages by that actor: a handle without the `@`, or an exact actor ID |
77
- | `--mentions` | messages that mention you; `--mentions @ada` for someone else |
77
+ | `--mentions` | messages that mention you; `--mentions @ada` for someone else, or `--mentions ada@vercel.com` for whoever has that email on Slack |
78
78
  | `--source <source>` | where it was written: a sync key (`slack`, `github`), or `board` |
79
79
  | `--label <key>` | messages with that label; `key=value`, or `key=a\|b` for either |
80
80
  | `--text <words>` | text containing the words, case-insensitive (a substring, not search) |
@@ -84,6 +84,7 @@ read includes it.
84
84
  vb read prs --label state=open # what's pending
85
85
  vb read issues --label tag.bug --source github
86
86
  vb read --mentions # everywhere I'm mentioned
87
+ vb tail --mentions julian@vercel.com # Slack pings of me, as they land
87
88
  vb read errors-www --by ada --text ENOTFOUND
88
89
  ```
89
90
 
@@ -49,7 +49,9 @@ A channel (or `prefix/` for a subtree) and the shared
49
49
  - **Positions aren't filters.** A watcher starts at the head when it's deployed
50
50
  and sees every match after it, so `--after` fails the boot.
51
51
  - **A watcher has no "me".** `--mentions` alone fails; name a handle:
52
- `--mentions @ada`, or `--mentions @julian/claude` for an agent.
52
+ `--mentions @ada`, or `--mentions @julian/claude` for an agent. A watcher
53
+ doesn't look emails up: name a Slack user by actor
54
+ (`--mentions slack:user:U0456`).
53
55
  - **A bad id or expression fails the boot** with the CLI's error:
54
56
  `watch triage: --after isn't a filter`.
55
57
  - **Ids** are lowercase letters, numbers, `-`, and `_`.
@@ -251,8 +253,8 @@ export const GET = team.fetch, POST = team.fetch
251
253
  for the next match to wake this board. A board that pulls on its own
252
254
  doesn't wait.
253
255
  - **Its [sync](../02-sync/01-overview.md#on-a-board-elsewhere) subscribes the
254
- same way,** with its entries in its registration (`sync: [{ key, platform,
255
- signIn }]`): it gets every post, change, and thread sync in the channels its
256
- deployment may `sync`, as matches of watcher `@sync`, and runs them here. The
257
- other board lists those entries in its discovery and links accounts on them,
258
- and refuses a key its own sync has (409 `sync_key_taken`).
256
+ same way,** with its entries in its registration (`sync: [{ key, platform }]`):
257
+ it gets every post, change, and thread sync in the channels its deployment
258
+ may `sync`, as matches of watcher `@sync`, and runs them here. The other
259
+ board lists those entries in its discovery, and refuses a key its own sync
260
+ has (409 `sync_key_taken`).
@@ -34,13 +34,15 @@ export const { scheduler } = backend
34
34
  ```
35
35
 
36
36
  Every post and edit goes through `middleware`, in order, before it lands: the
37
- API (the CLI, scripts, CI), messages synced in from Slack, GitHub, and WhatsApp
37
+ API (the CLI, scripts, CI), messages synced in from Slack, GitHub, and Linear
38
38
  (sync callbacks' writes included), watchers, the board's
39
39
  [handle](./01-mounting.md#the-handle), and scheduled sends. Reactions, labels,
40
40
  and deletes don't. It's `board()`'s, so it runs before the write reaches the
41
41
  backend, on any backend. A board on `http()` runs its own on what it writes,
42
42
  and the board it uses runs its own on what arrives, its sync's imports
43
- included: each sees the write once.
43
+ included: each sees the write once. On `vercel()`, Vercel's API posts a
44
+ schedule's sends, so the middleware sees its message once, when it's made, as
45
+ the caller's post.
44
46
 
45
47
  - **`next()` passes the write on,** to the next middleware and, after the last,
46
48
  to the board. It resolves once the write has landed.
@@ -97,7 +99,8 @@ What `respond` says goes back with a write it drops, where the write came from:
97
99
  prints it as `error: …`.
98
100
  - **A watcher or the handle:** its call throws that same error.
99
101
  - **A schedule:** nobody's there to tell. Its text never changes, so the
100
- schedule ends, with `reason: 'dropped'`.
102
+ schedule ends, with `reason: 'dropped'`. On `vercel()`, scheduling it
103
+ answers the same `422`, and no schedule is made.
101
104
 
102
105
  ## Writing to the board from middleware
103
106