@vercel/board 0.29.0 → 0.30.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 (124) hide show
  1. package/dist/{a2-HUbQJ0RD.mjs → a2-D_28Pdip.mjs} +190 -76
  2. package/dist/access-CQ39gqEV.mjs +226 -0
  3. package/dist/access.d.mts +1 -1
  4. package/dist/access.mjs +3 -3
  5. package/dist/{actors-DcJhFbwJ.mjs → actors-xPiamLWk.mjs} +2 -2
  6. package/dist/{auth-JO22iIwr.mjs → auth-BPinwfN0.mjs} +3 -226
  7. package/dist/backend-a2/blob-vercel.d.mts +1 -1
  8. package/dist/backend-a2/contract.d.mts +2 -2
  9. package/dist/backend-a2/contract.mjs +1 -1
  10. package/dist/backend-a2/scheduler-local.d.mts +1 -1
  11. package/dist/backend-a2/scheduler-local.mjs +1 -1
  12. package/dist/backend-a2/scheduler-qstash.d.mts +3 -3
  13. package/dist/backend-a2/scheduler-qstash.mjs +8 -8
  14. package/dist/backend-a2/scheduler-vercel.d.mts +3 -3
  15. package/dist/backend-a2/scheduler-vercel.mjs +6 -6
  16. package/dist/backend-a2/store-memory.d.mts +2 -2
  17. package/dist/backend-a2/store-memory.mjs +3 -3
  18. package/dist/backend-a2/store-postgres.d.mts +1 -1
  19. package/dist/backend-a2/store-postgres.mjs +34 -15
  20. package/dist/backend-a2/store-redis-http.d.mts +1 -1
  21. package/dist/backend-a2/store-redis-http.mjs +6 -6
  22. package/dist/backend-a2/store-redis.d.mts +2 -2
  23. package/dist/backend-a2/store-redis.mjs +5 -5
  24. package/dist/backend-a2/store-sqlite.d.mts +1 -1
  25. package/dist/backend-a2/store-sqlite.mjs +3 -3
  26. package/dist/backend-a2.d.mts +17 -7
  27. package/dist/backend-a2.mjs +2 -2
  28. package/dist/{backend-http-Bmp0IgD3.mjs → backend-http-16SbvnAK.mjs} +2 -2
  29. package/dist/{backend-http-CqfKhrnn.d.mts → backend-http-3PoBYQoo.d.mts} +13 -3
  30. package/dist/{backend-http-CzIJMm58.mjs → backend-http-BPaNYLWj.mjs} +2 -3
  31. package/dist/backend-http.d.mts +1 -1
  32. package/dist/backend-http.mjs +1 -1
  33. package/dist/backend-http.node.d.mts +1 -1
  34. package/dist/backend-http.node.mjs +1 -1
  35. package/dist/{backend-CPC_vyOo.d.mts → backend-n3hajfrD.d.mts} +11 -5
  36. package/dist/{backend-vercel-BNauAs7w.mjs → backend-vercel-C4Kee2-K.mjs} +203 -31
  37. package/dist/{backend-vercel-DQc_TaHp.mjs → backend-vercel-Kd8nvW5A.mjs} +3 -3
  38. package/dist/{backend-vercel-4tvcPKqX.d.mts → backend-vercel-qznuDxnN.d.mts} +16 -5
  39. package/dist/backend-vercel.d.mts +1 -1
  40. package/dist/backend-vercel.mjs +1 -1
  41. package/dist/backend-vercel.node.d.mts +1 -1
  42. package/dist/backend-vercel.node.mjs +1 -1
  43. package/dist/{blob-DmiQjrYy.d.mts → blob-CqILvdmr.d.mts} +2 -2
  44. package/dist/{blob-DdbpJqN1.mjs → blob-DQK5prXT.mjs} +1 -1
  45. package/dist/{chat-provider-BWHbChcQ.d.mts → chat-provider-D6jVT4wS.d.mts} +3 -3
  46. package/dist/cli-commands.d.mts +1 -1
  47. package/dist/cli-commands.mjs +1 -1
  48. package/dist/cli-embedded.d.mts +2 -2
  49. package/dist/cli-embedded.mjs +1 -1
  50. package/dist/cli.mjs +4 -4
  51. package/dist/{client-BqAjk0Q4.d.mts → client-CjAatpV4.d.mts} +60 -10
  52. package/dist/{commands-C3R1AWgm.mjs → commands-B5DHjZKK.mjs} +50 -9
  53. package/dist/{commands-D6gvrOh3.d.mts → commands-BtH69yqt.d.mts} +17 -0
  54. package/dist/{conformance-DlnfuzEB.mjs → conformance-C7fwIvmN.mjs} +258 -18
  55. package/dist/conformance.d.mts +1 -1
  56. package/dist/conformance.mjs +1 -1
  57. package/dist/{engine-DQxXk7dX.mjs → engine-DkdkXQEy.mjs} +2 -2
  58. package/dist/{errors-DDktVeUa.d.mts → errors-87TVtXeb.d.mts} +1 -1
  59. package/dist/{client-D0Px7KzF.mjs → errors-C_5rB8Iz.mjs} +104 -4
  60. package/dist/{exports-BG-0Wyn-.mjs → exports-CG0P8Qi4.mjs} +54 -28
  61. package/dist/exports-CrGA5_yN.d.mts +5 -0
  62. package/dist/{folds-ZkHM4M-v.d.mts → folds-8wRRXjAt.d.mts} +2 -0
  63. package/dist/{folds-BDMSxBUf.mjs → folds-DGYkMW19.mjs} +1 -0
  64. package/dist/{index-D3VvNfh_.d.mts → index-BygAXP7t.d.mts} +2 -2
  65. package/dist/index.d.mts +6 -6
  66. package/dist/{index.default-rB8sJ9RX.mjs → index.default-DhkgpfR2.mjs} +1 -1
  67. package/dist/index.mjs +5 -6
  68. package/dist/index.node.d.mts +6 -6
  69. package/dist/index.node.mjs +3 -4
  70. package/dist/inspect/app.js +29 -29
  71. package/dist/{internal-CvsrprnU-CRY89I-b.mjs → internal-CvsrprnU-zZMpDiRY.mjs} +1 -1
  72. package/dist/{local-DhkrAqPP.mjs → local-C76Fy5ad.mjs} +1 -1
  73. package/dist/{main-CVBqe3VO.mjs → main-D2s-QBOK.mjs} +70 -23
  74. package/dist/{middleware-CXjEeef4.mjs → middleware-DlJQYiwV.mjs} +1 -1
  75. package/dist/{node-Zd_C-gxP.mjs → node-D8Rtg1su.mjs} +1 -1
  76. package/dist/{platform-B4TnJtWu-CwcTyKg2.mjs → platform-B4TnJtWu-Do3agKr5.mjs} +1 -1
  77. package/dist/{project-CBXlLftU.mjs → project-C_2xs57L.mjs} +1 -1
  78. package/dist/{provider-DmSTp9lI.d.mts → provider-Bm46LAdp.d.mts} +2 -2
  79. package/dist/{redis-tables-BJQVUspu.mjs → redis-tables-BWJZHUGg.mjs} +40 -24
  80. package/dist/{reducer-CjmvZSAb-D_OSISmj.mjs → reducer-Bswr11ar-P324o9vn.mjs} +2 -2
  81. package/dist/{reducer-CYPHsSmd-BQIfozGi.d.mts → reducer-CYPHsSmd-BXjGiptm.d.mts} +1 -1
  82. package/dist/{reducers-DJzVpXwM.mjs → reducers-8jqLewgw.mjs} +3 -3
  83. package/dist/{retryable-lazy-DZWmHpii-Bye-W8q7.mjs → retryable-lazy-DZWmHpii-DpTc5PIP.mjs} +1 -1
  84. package/dist/{scheduler-task-X1SToDjP-BVoWuInI.mjs → scheduler-task-X1SToDjP-D2Il2rln.mjs} +1 -1
  85. package/dist/{serve-G9RuaDSr.mjs → serve-lPoBDEds.mjs} +7 -7
  86. package/dist/{server-qgez0Xny-D5mLfqFQ.d.mts → server-B9gu0qi1-DI1WvcVK.d.mts} +10 -4
  87. package/dist/{server-BixV_byP.mjs → server-D82a4uMe.mjs} +4 -4
  88. package/dist/{server-DtFrWxi3-Boa6gdWO.mjs → server-DX7mM6nm-D7N_7bug.mjs} +32 -23
  89. package/dist/{serving-B75Y4BF-.mjs → serving-DKny38LK.mjs} +1 -1
  90. package/dist/{session-clear-wHd5GCpZ-BnKSxReK.mjs → session-clear-XJYpmUpg-2q8l_FJR.mjs} +14 -2
  91. package/dist/{sql-D4Z_efJX.mjs → sql-Bj0fk6dn.mjs} +1 -1
  92. package/dist/{sqlite-db-Bz2JkfCU.mjs → sqlite-db-Dc8G78XV.mjs} +1 -1
  93. package/dist/{store-D0MhBvg6-4Z2L0Kqd.d.mts → store-DEzcHZOA-B-5vhxef.d.mts} +12 -2
  94. package/dist/store-N8PXxDAS-DWWgv-Vz.mjs +5 -0
  95. package/dist/{store-codec-DwrWlgQx-CYxGW0aD.mjs → store-codec-pAG2oPgQ-SqDXlPBR.mjs} +4 -4
  96. package/dist/store-memory-4IBl0Ztu.mjs +2 -0
  97. package/dist/{store-memory-Bas9Uq5C.mjs → store-memory-B0PkVSuQ.mjs} +14 -7
  98. package/dist/{store-polling-BZt2Rf3D-BiEM6_HB.mjs → store-polling-BZt2Rf3D-Div-bJ_D.mjs} +2 -2
  99. package/dist/{store-presence-polling-JuAy2Ck2-CYpuK75X.mjs → store-presence-polling-JuAy2Ck2-Dh-g_KYM.mjs} +3 -3
  100. package/dist/{store-sqlite-CxxPCcCT.mjs → store-sqlite-D85t3tE7.mjs} +14 -7
  101. package/dist/store-sqlite-iBTYYKBj.mjs +2 -0
  102. package/dist/{stream-buffer-DrTyiIuo-BKy-vI14.mjs → stream-buffer-DrTyiIuo-Be10yOBR.mjs} +2 -2
  103. package/dist/sync-github.d.mts +2 -2
  104. package/dist/sync-kapso.d.mts +2 -2
  105. package/dist/sync-linear.d.mts +3 -3
  106. package/dist/sync-linear.mjs +4 -2
  107. package/dist/sync-slack.d.mts +3 -3
  108. package/dist/sync.chat.d.mts +1 -1
  109. package/dist/sync.d.mts +2 -2
  110. package/dist/{tokens-CC3izSNr.mjs → tokens-C5_yGRlL.mjs} +2 -2
  111. package/dist/{types-No0WdhE5.d.mts → types-shZmGZ-q.d.mts} +2 -2
  112. package/dist/{watchers-hash-BDhLVkV5.mjs → watchers-hash-B_5b0-ZG.mjs} +46 -17
  113. package/docs/01-board/04-messages.md +93 -0
  114. package/docs/01-board/08-middleware.md +5 -2
  115. package/docs/04-reference/01-cli.md +23 -10
  116. package/docs/04-reference/02-board.md +20 -5
  117. package/docs/04-reference/03-http.md +17 -6
  118. package/docs/04-reference/05-conformance.md +14 -3
  119. package/package.json +2 -2
  120. package/dist/errors-CusN4x2l.mjs +0 -88
  121. package/dist/exports-mhAYiU4i.d.mts +0 -5
  122. package/dist/store-N8PXxDAS-BWBgzBHp.mjs +0 -5
  123. package/dist/store-memory-ZLiZbH3R.mjs +0 -2
  124. package/dist/store-sqlite-BmwL3pUR.mjs +0 -2
@@ -1,4 +1,5 @@
1
- import { t as BoardError } from "./client-D0Px7KzF.mjs";
1
+ import { u as BoardError } from "./errors-C_5rB8Iz.mjs";
2
+ import { r as compileAccess } from "./access-CQ39gqEV.mjs";
2
3
  //#region src/conformance/index.ts
3
4
  var Mismatch = class extends Error {};
4
5
  const show = (value) => JSON.stringify(value) ?? String(value);
@@ -41,6 +42,31 @@ const needRoot = (ctx) => {
41
42
  reply: ctx.reply
42
43
  };
43
44
  };
45
+ /** The sync key the sync checks register, a platform of their own, so nothing reaches a real one. */
46
+ const SYNC_KEY = "conformance";
47
+ /**
48
+ * Registers a sync on `SYNC_KEY` for a check, and says where to put the token's registration back
49
+ * after: or why it can't, since registering replaces the token's watchers.
50
+ */
51
+ async function registerSync(ctx) {
52
+ const registered = (await ctx.board.watchers()).find((registration) => registration.owner === ctx.me.principal);
53
+ if (registered && (Object.keys(registered.watch).length > 0 || (registered.sync?.length ?? 0) > 0)) return { skip: "this token has watchers or a sync registered here, which registering would replace" };
54
+ const where = wokenAt(registered);
55
+ try {
56
+ await ctx.board.register({
57
+ url: null,
58
+ watch: {},
59
+ sync: [{
60
+ key: SYNC_KEY,
61
+ platform: SYNC_KEY
62
+ }]
63
+ });
64
+ } catch (error) {
65
+ if (error instanceof BoardError && error.status === 403) return { skip: "this token can't register a sync here" };
66
+ throw error;
67
+ }
68
+ return { where };
69
+ }
44
70
  async function post(ctx, text, extra = {}) {
45
71
  const message = await ctx.board.post(ctx.channel, {
46
72
  text,
@@ -51,6 +77,15 @@ async function post(ctx, text, extra = {}) {
51
77
  ctx.posted.push(message.id);
52
78
  return message;
53
79
  }
80
+ async function reply(ctx, re, text, extra = {}) {
81
+ const message = await ctx.board.reply(re, {
82
+ text,
83
+ noSync: true,
84
+ ...extra
85
+ });
86
+ ctx.posted.push(message.id);
87
+ return message;
88
+ }
54
89
  const CHECKS = [
55
90
  {
56
91
  group: "core",
@@ -80,6 +115,31 @@ const CHECKS = [
80
115
  check((await ctx.board.thread(root.id)).replies.some((m) => m.id === reply.id), `the thread's replies should include ${reply.id}`);
81
116
  }
82
117
  },
118
+ {
119
+ group: "core",
120
+ name: "replies after a message only while it's the thread's newest, one of any that race",
121
+ run: async (ctx) => {
122
+ const root = await post(ctx, "conformance: a thread to reply after");
123
+ const first = await reply(ctx, root.id, "conformance: after the root", { after: root.id });
124
+ await rejects(reply(ctx, root.id, "conformance: late", { after: root.id }), 409, "newer_message", "a reply after the root, once another came after it");
125
+ same((await ctx.board.thread(root.id)).replies.length, 1, "the replies after a refused one");
126
+ const deleted = await reply(ctx, root.id, "conformance: deleted", { after: first.id });
127
+ await ctx.board.delete(deleted.id);
128
+ await rejects(reply(ctx, root.id, "conformance: after a deleted one", { after: first.id }), 409, "newer_message", "a reply after a message a deleted one came after");
129
+ const racing = await Promise.allSettled([
130
+ 1,
131
+ 2,
132
+ 3
133
+ ].map((n) => reply(ctx, root.id, `conformance: racing ${n}`, { after: deleted.id })));
134
+ same(racing.filter((result) => result.status === "fulfilled").length, 1, "the replies that landed of three racing after the same message");
135
+ for (const result of racing) {
136
+ if (result.status === "fulfilled") continue;
137
+ const error = result.reason;
138
+ check(error instanceof BoardError && error.status === 409 && error.code === "newer_message", `a reply that lost the race should be 409 newer_message, got ${error instanceof BoardError ? `${error.status} ${error.code}` : String(error)}`);
139
+ }
140
+ same((await ctx.board.thread(root.id)).replies.length, 3, "the replies after the race, a deleted one included");
141
+ }
142
+ },
83
143
  {
84
144
  group: "core",
85
145
  name: "posts once for a client ID, however many times it is sent",
@@ -116,6 +176,78 @@ const CHECKS = [
116
176
  same((await version()).labels.state, void 0, "the label unset");
117
177
  }
118
178
  },
179
+ {
180
+ group: "core",
181
+ name: "labels a message only while its labels are as the write says, one of any that race",
182
+ run: async (ctx) => {
183
+ const message = await post(ctx, "conformance: a message to claim");
184
+ const labels = async () => (await ctx.board.thread(message.id)).message.labels;
185
+ const racing = await Promise.allSettled([
186
+ 1,
187
+ 2,
188
+ 3
189
+ ].map((n) => ctx.board.label(message.id, {
190
+ set: { owner: `agent-${n}` },
191
+ if: { owner: null }
192
+ })));
193
+ const landed = racing.flatMap((result, n) => result.status === "fulfilled" ? [`agent-${n + 1}`] : []);
194
+ same(landed.length, 1, "the claims that landed of three racing where no one had one");
195
+ for (const result of racing) {
196
+ if (result.status === "fulfilled") continue;
197
+ const error = result.reason;
198
+ check(error instanceof BoardError && error.status === 409 && error.code === "label_conflict", `a claim that lost the race should be 409 label_conflict, got ${error instanceof BoardError ? `${error.status} ${error.code}` : String(error)}`);
199
+ }
200
+ same((await labels()).owner, landed[0], "the owner, after the race");
201
+ await rejects(ctx.board.label(message.id, {
202
+ set: { owner: "someone" },
203
+ if: { owner: "no-one" }
204
+ }), 409, "label_conflict", "a label write from a value the label doesn't have");
205
+ same((await labels()).owner, landed[0], "the owner, after a write from the wrong value");
206
+ await ctx.board.label(message.id, {
207
+ unset: ["owner"],
208
+ if: { owner: landed[0] }
209
+ });
210
+ same((await labels()).owner, void 0, "the owner, released from the value it had");
211
+ }
212
+ },
213
+ {
214
+ group: "core",
215
+ name: "edits and deletes a message only at the revision it was read at, which only edits and deletes change",
216
+ run: async (ctx) => {
217
+ const message = await post(ctx, "conformance: a message to edit");
218
+ const read = async () => (await ctx.board.thread(message.id)).message;
219
+ const first = (await read()).revision;
220
+ check(typeof first === "number", `a message's revision should be a number, got ${show(first)}`);
221
+ await ctx.board.react(message.id, "eyes");
222
+ await ctx.board.label(message.id, { set: { state: "open" } });
223
+ same((await read()).revision, first, "the revision after a reaction and a label");
224
+ await ctx.board.edit(message.id, "conformance: edited", { revision: first });
225
+ const second = (await read()).revision;
226
+ check(second !== first, `an edit should change the revision from ${first}`);
227
+ await rejects(ctx.board.edit(message.id, "conformance: stale", { revision: first }), 409, "revision_conflict", "an edit at a revision an edit came after");
228
+ await rejects(ctx.board.delete(message.id, { revision: first }), 409, "revision_conflict", "a delete at a revision an edit came after");
229
+ const after = await read();
230
+ check(after.text === "conformance: edited" && !after.deleted, `refused writes should leave it as it was, got ${show({
231
+ text: after.text,
232
+ deleted: after.deleted
233
+ })}`);
234
+ const racing = await Promise.allSettled([
235
+ 1,
236
+ 2,
237
+ 3
238
+ ].map((n) => ctx.board.edit(message.id, `conformance: racing ${n}`, { revision: second })));
239
+ same(racing.filter((result) => result.status === "fulfilled").length, 1, "the edits that landed of three racing at the same revision");
240
+ for (const result of racing) {
241
+ if (result.status === "fulfilled") continue;
242
+ const error = result.reason;
243
+ check(error instanceof BoardError && error.status === 409 && error.code === "revision_conflict", `an edit that lost the race should be 409 revision_conflict, got ${error instanceof BoardError ? `${error.status} ${error.code}` : String(error)}`);
244
+ }
245
+ const last = (await read()).revision;
246
+ await ctx.board.delete(message.id, { revision: last });
247
+ check((await read()).deleted, "a delete at the revision it has should delete it");
248
+ await rejects(ctx.board.edit(message.id, "conformance: too late", { revision: last }), 409, "revision_conflict", "an edit at a revision a delete came after");
249
+ }
250
+ },
119
251
  {
120
252
  group: "core",
121
253
  name: "matches a label on the message or its thread's root",
@@ -261,6 +393,24 @@ const CHECKS = [
261
393
  same(canceled.state, "canceled", "the canceled schedule");
262
394
  }
263
395
  },
396
+ {
397
+ group: "schedules",
398
+ name: "keeps noSync on a schedule, so its sends stay off every platform",
399
+ run: async (ctx) => {
400
+ const { root } = needRoot(ctx);
401
+ const schedule = await ctx.board.schedule({ re: root.id }, {
402
+ text: "conformance: later, on the board only",
403
+ noSync: true,
404
+ in: "1d"
405
+ });
406
+ try {
407
+ same(schedule.noSync, true, "the schedule's noSync");
408
+ same((await ctx.board.schedules({ re: root.id })).find((s) => s.id === schedule.id)?.noSync, true, "the listed schedule's noSync");
409
+ } finally {
410
+ await ctx.board.cancelSchedule(schedule.id).catch(() => {});
411
+ }
412
+ }
413
+ },
264
414
  {
265
415
  group: "attachments",
266
416
  name: "takes a file, posts it, and gives it back",
@@ -341,7 +491,7 @@ const CHECKS = [
341
491
  group: "sync",
342
492
  name: "keeps sync's records: an import as a platform author, its lookups by platform ID, a receipt, and where it's synced",
343
493
  run: async (ctx) => {
344
- const key = "conformance";
494
+ const key = SYNC_KEY;
345
495
  const thread = `${key}:${ctx.labels.conformance}:1`;
346
496
  const author = {
347
497
  id: `${key}:user:${ctx.labels.conformance}`,
@@ -354,22 +504,9 @@ const CHECKS = [
354
504
  thread,
355
505
  message: "1"
356
506
  };
357
- const registered = (await ctx.board.watchers()).find((registration) => registration.owner === ctx.me.principal);
358
- if (registered && (Object.keys(registered.watch).length > 0 || (registered.sync?.length ?? 0) > 0)) return "this token has watchers or a sync registered here, which registering would replace";
359
- const where = wokenAt(registered);
360
- try {
361
- await ctx.board.register({
362
- url: null,
363
- watch: {},
364
- sync: [{
365
- key,
366
- platform: key
367
- }]
368
- });
369
- } catch (error) {
370
- if (error instanceof BoardError && error.status === 403) return "this token can't register a sync here";
371
- throw error;
372
- }
507
+ const registered = await registerSync(ctx);
508
+ if ("skip" in registered) return registered.skip;
509
+ const { where } = registered;
373
510
  let imported;
374
511
  try {
375
512
  imported = await ctx.board.syncImport({
@@ -477,6 +614,109 @@ const CHECKS = [
477
614
  }).catch(() => {});
478
615
  }
479
616
  }
617
+ },
618
+ {
619
+ group: "sync",
620
+ name: "queues a platform thread's events in its sync's inbox once, and leases each after the one before is acked",
621
+ run: async (ctx) => {
622
+ const registered = await registerSync(ctx);
623
+ if ("skip" in registered) return registered.skip;
624
+ try {
625
+ const run = ctx.labels.conformance;
626
+ const author = {
627
+ id: `${SYNC_KEY}:user:${run}`,
628
+ handle: "conformance",
629
+ name: "Conformance",
630
+ avatar: null,
631
+ isBot: false
632
+ };
633
+ const thread = `${SYNC_KEY}:${run}:inbox`;
634
+ const eventOf = (message) => ({
635
+ id: `conformance-inbox-${run}-${message}`,
636
+ event: {
637
+ kind: "message",
638
+ channel: `${SYNC_KEY}:${run}`,
639
+ thread,
640
+ subthread: null,
641
+ message,
642
+ isRoot: message === "1",
643
+ text: `conformance: inbox event ${message}`,
644
+ context: null,
645
+ author
646
+ },
647
+ raw: {
648
+ conformance: run,
649
+ message
650
+ }
651
+ });
652
+ const pushed = [eventOf("1"), eventOf("2")];
653
+ await ctx.board.inboxPush(SYNC_KEY, pushed);
654
+ await ctx.board.inboxPush(SYNC_KEY, [pushed[0]]);
655
+ const lease = async () => (await ctx.board.inboxNext(SYNC_KEY, { limit: 50 })).filter((item) => item.thread === thread);
656
+ for (const { event, raw } of pushed) {
657
+ const leased = await lease();
658
+ same(leased.length, 1, `the events leased from the platform thread, for message ${event.message}`);
659
+ const item = leased[0];
660
+ same(item.event.kind === "message" ? item.event.message : null, event.message, "the platform message leased next");
661
+ same(JSON.stringify(item.raw), JSON.stringify(raw), "the leased event's raw payload");
662
+ await ctx.board.inboxAck(SYNC_KEY, item.id, { lease: item.lease });
663
+ }
664
+ same((await lease()).length, 0, "the events leased once both are acked");
665
+ } finally {
666
+ await ctx.board.register({
667
+ ...registered.where,
668
+ watch: {}
669
+ }).catch(() => {});
670
+ }
671
+ }
672
+ },
673
+ {
674
+ group: "sync",
675
+ name: "hands a sync each message change as a match of its @sync watcher",
676
+ run: async (ctx) => {
677
+ const access = compileAccess(ctx.me.can);
678
+ if (access.anywhere("sync") && !access.can("sync", ctx.channel)) return `this token can't sync in ${ctx.channel}`;
679
+ const registered = await registerSync(ctx);
680
+ if ("skip" in registered) return registered.skip;
681
+ try {
682
+ const message = await post(ctx, "conformance: for a sync");
683
+ const deadline = Date.now() + 1e4;
684
+ let match;
685
+ while (!match && Date.now() < deadline) match = (await ctx.board.matches({ wait: 5 })).find((m) => m.watcher === "@sync" && m.event.message?.id === message.id);
686
+ check(match, "a pull should hand the sync a match for the post, as its @sync watcher's");
687
+ same(match.event.type, "message.posted", "the sync's match");
688
+ await ctx.board.ack(match.id, { lease: match.lease });
689
+ } finally {
690
+ await ctx.board.register({
691
+ ...registered.where,
692
+ watch: {}
693
+ }).catch(() => {});
694
+ }
695
+ }
696
+ },
697
+ {
698
+ group: "sync",
699
+ name: "keeps noSync on a post and a reply, so sync sends neither",
700
+ run: async (ctx) => {
701
+ const { root, reply } = needRoot(ctx);
702
+ const thread = await ctx.board.thread(root.id);
703
+ same(thread.message.noSync, true, "a post's noSync, read back");
704
+ same(thread.replies.find((m) => m.id === reply.id)?.noSync, true, "a reply's noSync, read back");
705
+ }
706
+ },
707
+ {
708
+ group: "sync",
709
+ name: "finds no one for a platform account nobody linked",
710
+ run: async (ctx) => {
711
+ let found;
712
+ try {
713
+ found = await ctx.board.syncFind({ account: `${SYNC_KEY}:user:${ctx.labels.conformance}` });
714
+ } catch (error) {
715
+ if (error instanceof BoardError && error.status === 403) return "this token can't look up a sync here";
716
+ throw error;
717
+ }
718
+ same(found.person, null, "who an account nobody linked is");
719
+ }
480
720
  }
481
721
  ];
482
722
  /** Runs the checks against `board`, in order, and reports each. It never throws for what the board answers. */
@@ -1,4 +1,4 @@
1
- import { d as Board } from "./backend-CPC_vyOo.mjs";
1
+ import { d as Board } from "./backend-n3hajfrD.mjs";
2
2
  //#region src/conformance/index.d.ts
3
3
  /**
4
4
  * Checks that a board speaks the protocol, through a handle on it (`conformance(board())`), as
@@ -1,2 +1,2 @@
1
- import { t as conformance } from "./conformance-DlnfuzEB.mjs";
1
+ import { t as conformance } from "./conformance-C7fwIvmN.mjs";
2
2
  export { conformance };
@@ -2,10 +2,10 @@ import { a as typeOf, i as fileSize, r as IMPORT_BYTES_MAX, t as ATTACHMENT_BYTE
2
2
  import { C as toStorable, c as ChannelPath, m as MESSAGE_TEXT_MAX } from "./schemas-DBmUO6pr.mjs";
3
3
  import { c as externalMessageKey, l as isThreadTarget, s as channelOf } from "./external-D3p5T57R.mjs";
4
4
  import { t as canonicalEmoji } from "./emoji-CTLpvH-A.mjs";
5
+ import { r as badRequest, s as notFound, t as HttpError } from "./errors-C_5rB8Iz.mjs";
5
6
  import { r as derivedId, t as waitUntil } from "./wait-until-Dw4XrIDQ.mjs";
6
- import { r as badRequest, s as notFound, t as HttpError } from "./errors-CusN4x2l.mjs";
7
7
  import { n as replaceMentions } from "./mentions-CB6ShY4R.mjs";
8
- import { t as DroppedError } from "./middleware-CXjEeef4.mjs";
8
+ import { t as DroppedError } from "./middleware-DlJQYiwV.mjs";
9
9
  import { t as afterRetry } from "./attempts-HXcskN1w.mjs";
10
10
  import { a as channelMissing, i as UnreadableError, n as ResolvedTarget, o as routesFor, t as ReceivedEvent } from "./provider-BaVmi4Hi.mjs";
11
11
  import { a as immutable, n as SyncOverrides } from "./events-qf5XG5VQ.mjs";
@@ -1,4 +1,4 @@
1
- import { r as ErrorOrigin } from "./client-BqAjk0Q4.mjs";
1
+ import { r as ErrorOrigin } from "./client-CjAatpV4.mjs";
2
2
  //#region src/server/errors.d.ts
3
3
  declare class HttpError extends Error {
4
4
  readonly status: number;
@@ -1,5 +1,6 @@
1
1
  import { a as typeOf, n as DIRECT_UPLOAD_BYTES_MAX, t as ATTACHMENT_BYTES_MAX } from "./files-SniSmeEr.mjs";
2
2
  import { r as normalizeEmoji } from "./emoji-CTLpvH-A.mjs";
3
+ import { ZodError } from "zod";
3
4
  //#region src/client/sse.ts
4
5
  /** Parses a text/event-stream body into messages. */
5
6
  async function* parseSse(body, signal) {
@@ -230,10 +231,13 @@ function createBoardClient(options) {
230
231
  const size = input.data instanceof Blob ? input.data.size : input.data.byteLength;
231
232
  if (size > 4194304) {
232
233
  if (size > 104857600) throw new BoardError("too_large", `Attachments are at most ${DIRECT_UPLOAD_BYTES_MAX / 1024 / 1024} MB.`, 413);
234
+ const bytes = input.data instanceof Blob ? await input.data.arrayBuffer() : input.data;
235
+ const sha256 = btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))));
233
236
  const ticket = await call("POST", "/attachments/uploads", { body: {
234
237
  name: input.name,
235
238
  type,
236
- size
239
+ size,
240
+ sha256
237
241
  } }).catch((error) => {
238
242
  if (error instanceof BoardError && error.status === 404 && error.code === "not_found") throw new BoardError("too_large", `This board takes files of at most ${ATTACHMENT_BYTES_MAX / 1024 / 1024} MB.`, 413);
239
243
  throw error;
@@ -260,13 +264,24 @@ function createBoardClient(options) {
260
264
  },
261
265
  /** An attachment's bytes, as the board serves them: `content-type` is the file's type. */
262
266
  download: (id, opts = {}) => raw("GET", `/attachments/${encodeURIComponent(id)}`, opts.signal ? { signal: opts.signal } : {}),
263
- edit: (id, text) => call("POST", `/messages/${path(id)}/edit`, { body: { text } }),
264
- delete: (id) => call("POST", `/messages/${path(id)}/delete`, { body: {} }),
267
+ /**
268
+ * `revision` is the message's as you read it: if it's been edited or deleted since, nothing
269
+ * changes, and it throws a 409 `revision_conflict`.
270
+ */
271
+ edit: (id, text, opts = {}) => call("POST", `/messages/${path(id)}/edit`, { body: {
272
+ text,
273
+ ...opts
274
+ } }),
275
+ delete: (id, opts = {}) => call("POST", `/messages/${path(id)}/delete`, { body: { ...opts } }),
265
276
  /** `emoji` is a short name, `:name:`, or the character itself. */
266
277
  react: (id, emoji, opts = {}) => call("POST", `/messages/${path(id)}/reactions`, { body: {
267
278
  emoji: normalizeEmoji(emoji),
268
279
  ...opts
269
280
  } }),
281
+ /**
282
+ * `if` is what the labels must be now, a value or null for one that mustn't be set: if one
283
+ * isn't, nothing changes, and it throws a 409 `label_conflict`.
284
+ */
270
285
  label: (id, change) => call("POST", `/messages/${path(id)}/labels`, { body: change }),
271
286
  /**
272
287
  * Shows typing in the platform threads the message's thread is synced to
@@ -405,4 +420,89 @@ async function toError(response) {
405
420
  return new BoardError("http_error", `The board answered ${`${response.status} ${response.statusText}`.trim()}.`, response.status);
406
421
  }
407
422
  //#endregion
408
- export { handleErrors as i, createBoardClient as n, duration as r, BoardError as t };
423
+ //#region src/server/errors.ts
424
+ var HttpError = class extends Error {
425
+ status;
426
+ code;
427
+ /** What fixes it, when there's one (`BoardError`'s `hint`). */
428
+ hint;
429
+ /** `handle` for a handle's own error on its way through: everything else is the board's. */
430
+ origin;
431
+ constructor(status, code, message, options = {}) {
432
+ super(message);
433
+ this.status = status;
434
+ this.code = code;
435
+ this.hint = options.hint;
436
+ this.origin = options.origin ?? "board";
437
+ }
438
+ };
439
+ /** A request a host's own verifier rejected with `response`, which the board answers with as it is. */
440
+ var RejectedError = class extends HttpError {
441
+ response;
442
+ constructor(response) {
443
+ super(response.status, "rejected", `rejected with ${response.status}`);
444
+ this.response = response;
445
+ }
446
+ };
447
+ const badRequest = (message, code = "invalid_request") => new HttpError(400, code, message);
448
+ const unauthorized = (message) => new HttpError(401, "unauthorized", message);
449
+ const forbidden = (message) => new HttpError(403, "forbidden", message);
450
+ const notFound = (message, code = "not_found") => new HttpError(404, code, message);
451
+ const duplicateId = () => new HttpError(409, "duplicate_id", "that id was already used for a different write");
452
+ const json = (body, status = 200, headers) => new Response(JSON.stringify(body), {
453
+ status,
454
+ headers: {
455
+ "content-type": "application/json",
456
+ ...headers
457
+ }
458
+ });
459
+ /**
460
+ * The issue a request is told about. A union that matched no option says only "Invalid input", so
461
+ * it's the first issue of the option that came closest, the one with the fewest, at its own path.
462
+ */
463
+ function reported(issue) {
464
+ if (issue.code !== "invalid_union" || issue.errors.length === 0) return issue;
465
+ const first = issue.errors.reduce((best, option) => option.length < best.length ? option : best)[0];
466
+ return first ? reported({
467
+ ...first,
468
+ path: [...issue.path, ...first.path]
469
+ }) : issue;
470
+ }
471
+ /** Every failure becomes `{ error: { code, message, hint? } }` with a matching status. */
472
+ function toErrorResponse(error) {
473
+ if (error instanceof RejectedError) return error.response;
474
+ if (error instanceof HttpError) {
475
+ const response = json({ error: {
476
+ code: error.code,
477
+ message: error.message,
478
+ ...error.hint !== void 0 ? { hint: error.hint } : {}
479
+ } }, error.status);
480
+ if (error.origin === "handle") handleErrors.add(response);
481
+ return response;
482
+ }
483
+ if (error instanceof ZodError) {
484
+ const issue = error.issues[0] && reported(error.issues[0]);
485
+ const path = issue?.path.length ? `${issue.path.join(".")}: ` : "";
486
+ return json({ error: {
487
+ code: "invalid_request",
488
+ message: `${path}${issue?.message ?? "invalid request"}`
489
+ } }, 400);
490
+ }
491
+ const code = error?.code;
492
+ if (code === "INVALID_PAYLOAD") return json({ error: {
493
+ code: "invalid_request",
494
+ message: error.message
495
+ } }, 400);
496
+ if (code === "PARTIAL_DUPLICATE_BATCH" || code === "DUPLICATE_EVENT_ID") return toErrorResponse(duplicateId());
497
+ if (code === "STORE_UNAVAILABLE" || code === "STORE_NOT_CONFIGURED") return json({ error: {
498
+ code: "unavailable",
499
+ message: error.message
500
+ } }, 503);
501
+ console.error("[board]", error);
502
+ return json({ error: {
503
+ code: "internal",
504
+ message: "internal error"
505
+ } }, 500);
506
+ }
507
+ //#endregion
508
+ export { forbidden as a, toErrorResponse as c, createBoardClient as d, duration as f, duplicateId as i, unauthorized as l, RejectedError as n, json as o, badRequest as r, notFound as s, HttpError as t, BoardError as u };
@@ -1,12 +1,11 @@
1
1
  import { t as ATTACHMENT_BYTES_MAX } from "./files-SniSmeEr.mjs";
2
2
  import "./schemas-DBmUO6pr.mjs";
3
- import { A as SchedulesQuery, B as SyncReceiptBody, C as MatchesQuery, D as Ref, E as ReactBody, F as SyncBody, G as UploadStartBody, H as SyncUnlinkBody, I as SyncCodeBody, L as SyncCopiesQuery, M as SourceJoinBody, N as SourceReadQuery, O as RegisterBody, P as SyncAccountBody, R as SyncFindQuery, S as LinkedQuery, T as PostBody, U as ThreadsQuery, V as SyncRootsQuery, W as TypingBody, _ as InboxAckBody, a as METHODS, b as InboxQuery, c as queryObject, d as VERSION, f as SYNC, g as IdentityUnlinkBody, h as EventsQuery, j as SignInQuery, k as ScheduleBody, l as readCapped, m as EditBody, o as createRouter, p as AckBody, s as openapi, u as readJson, v as InboxNextQuery, w as MessagesQuery, x as LabelBody, y as InboxPushBody, z as SyncImportBody } from "./watchers-hash-BDhLVkV5.mjs";
4
- import { n as createBoardClient, t as BoardError } from "./client-D0Px7KzF.mjs";
3
+ import { A as ScheduleBody, B as SyncImportBody, C as LinkedQuery, D as ReactBody, E as PostBody, F as SyncAccountBody, G as TypingBody, H as SyncRootsQuery, I as SyncBody, K as UploadStartBody, L as SyncCodeBody, M as SignInQuery, N as SourceJoinBody, O as Ref, P as SourceReadQuery, R as SyncCopiesQuery, S as LabelBody, T as MessagesQuery, U as SyncUnlinkBody, V as SyncReceiptBody, W as ThreadsQuery, _ as IdentityUnlinkBody, a as METHODS, b as InboxPushBody, c as queryObject, d as VERSION, f as SYNC, g as EventsQuery, h as EditBody, j as SchedulesQuery, k as RegisterBody, l as readCapped, m as DeleteBody, o as createRouter, p as AckBody, s as openapi, u as readJson, v as InboxAckBody, w as MatchesQuery, x as InboxQuery, y as InboxNextQuery, z as SyncFindQuery } from "./watchers-hash-B_5b0-ZG.mjs";
4
+ import { a as forbidden, c as toErrorResponse, d as createBoardClient, l as unauthorized, n as RejectedError, o as json, r as badRequest, s as notFound, t as HttpError, u as BoardError } from "./errors-C_5rB8Iz.mjs";
5
5
  import { r as derivedId } from "./wait-until-Dw4XrIDQ.mjs";
6
- import { a as forbidden, c as toErrorResponse, l as unauthorized, n as RejectedError, o as json, r as badRequest, s as notFound, t as HttpError } from "./errors-CusN4x2l.mjs";
7
- import { d as issuersOf, g as verifiesTokens, l as hostPrincipal, o as createAuthority, s as fromTokens } from "./auth-JO22iIwr.mjs";
8
- import { i as resolveActor, r as claimsOf, t as KNOWN_AGENTS } from "./actors-DcJhFbwJ.mjs";
9
- import { r as runMiddleware, t as DroppedError } from "./middleware-CXjEeef4.mjs";
6
+ import { i as resolveActor, r as claimsOf, t as KNOWN_AGENTS } from "./actors-xPiamLWk.mjs";
7
+ import { d as issuersOf, g as verifiesTokens, l as hostPrincipal, o as createAuthority, s as fromTokens } from "./auth-BPinwfN0.mjs";
8
+ import { r as runMiddleware, t as DroppedError } from "./middleware-DlJQYiwV.mjs";
10
9
  import { n as syncTarget, r as withEmailMentions, t as mentionsEmails } from "./targets-Cgjs29T0.mjs";
11
10
  //#region src/server/policy.ts
12
11
  /**
@@ -216,7 +215,6 @@ async function inputOf(name, request, url, params) {
216
215
  case "message":
217
216
  case "posted":
218
217
  case "synced":
219
- case "delete":
220
218
  case "cancelSchedule":
221
219
  case "attachment":
222
220
  case "uploadFinish": return { id };
@@ -237,6 +235,10 @@ async function inputOf(name, request, url, params) {
237
235
  id,
238
236
  ...EditBody.parse(await body())
239
237
  };
238
+ case "delete": return {
239
+ id,
240
+ ...DeleteBody.parse(await body())
241
+ };
240
242
  case "react": return {
241
243
  id,
242
244
  ...ReactBody.parse(await body())
@@ -513,6 +515,25 @@ async function createServe(context) {
513
515
  }));
514
516
  }
515
517
  /**
518
+ * A schedule, on a backend whose sends post elsewhere: its message through the middleware once,
519
+ * as the caller's post of it, since its text and labels never change and no send comes back here.
520
+ */
521
+ function screenSchedule(caller, input, signal) {
522
+ const { channel, re, text, labels, noSync, attachments } = input;
523
+ return screenPost(caller, {
524
+ ...channel !== void 0 ? { channel } : {},
525
+ ...re !== void 0 ? { re } : {},
526
+ text,
527
+ ...labels ? { labels } : {},
528
+ ...noSync !== void 0 ? { noSync } : {},
529
+ ...attachments ? { attachments } : {}
530
+ }, signal, (screened) => backend.schedule(caller, {
531
+ ...input,
532
+ text: screened.text,
533
+ ...screened.labels ? { labels: screened.labels } : {}
534
+ }, signal));
535
+ }
536
+ /**
516
537
  * The caller's own message, still there, where it may post: what an edit of it changes. One it
517
538
  * can't read may still be its own, as board()'s own code finds it, only if it was written there
518
539
  * without reading too: one its principal wrote where it reads (a repo's workflows share one) is
@@ -709,22 +730,27 @@ async function createServe(context) {
709
730
  switch (name) {
710
731
  case "post": return importPost(caller, input, signal);
711
732
  case "edit": {
712
- const { id, text } = input;
733
+ const { id, text, revision } = input;
713
734
  return change({
714
735
  op: "edit",
715
736
  author,
716
737
  from: null,
717
738
  message: id,
718
739
  text: await withEmails(text),
719
- source
740
+ source,
741
+ ...revision !== void 0 ? { revision } : {}
742
+ });
743
+ }
744
+ case "delete": {
745
+ const { id, revision } = input;
746
+ return change({
747
+ op: "delete",
748
+ author,
749
+ from: null,
750
+ message: id,
751
+ ...revision !== void 0 ? { revision } : {}
720
752
  });
721
753
  }
722
- case "delete": return change({
723
- op: "delete",
724
- author,
725
- from: null,
726
- message: input.id
727
- });
728
754
  case "react": {
729
755
  const { id, emoji, remove } = input;
730
756
  return change({
@@ -737,14 +763,15 @@ async function createServe(context) {
737
763
  });
738
764
  }
739
765
  case "label": {
740
- const { id, set, unset } = input;
766
+ const { id, set, unset, if: expected } = input;
741
767
  return change({
742
768
  op: "labels",
743
769
  author,
744
770
  from: null,
745
771
  message: id,
746
772
  ...set ? { set } : {},
747
- ...unset ? { unset } : {}
773
+ ...unset ? { unset } : {},
774
+ ...expected ? { if: expected } : {}
748
775
  });
749
776
  }
750
777
  default: return invoke(name, me, input, signal);
@@ -780,7 +807,8 @@ async function createServe(context) {
780
807
  ...input.labels ? { labels: input.labels } : {},
781
808
  ...input.noSync ? { noSync: true } : {},
782
809
  ...input.attachments?.length ? { attachments: input.attachments } : {},
783
- ...input.id !== void 0 ? { id: input.id } : {}
810
+ ...input.id !== void 0 ? { id: input.id } : {},
811
+ ...input.after !== void 0 ? { after: input.after } : {}
784
812
  }, signal));
785
813
  for (const target of targets) await backend.syncReceipt(me, {
786
814
  kind: "thread",
@@ -796,27 +824,25 @@ async function createServe(context) {
796
824
  switch (name) {
797
825
  case "post": return post(caller, input, signal);
798
826
  case "edit": {
799
- const { id, text } = input;
827
+ const edit = input;
800
828
  const send = (body) => screenEdit(caller, body, signal, (patched) => backend.edit(caller, patched, signal));
801
- if (!engine || !mentionsEmails(text) || !await mayPost(caller, { re: id }, signal)) return send({
802
- id,
803
- text
804
- });
829
+ if (!engine || !mentionsEmails(edit.text) || !await mayPost(caller, { re: edit.id }, signal)) return send(edit);
805
830
  return send({
806
- id,
807
- text: await withEmails(text)
831
+ ...edit,
832
+ text: await withEmails(edit.text)
808
833
  });
809
834
  }
810
835
  case "syncImport": return syncImport(caller, input, signal);
811
836
  case "schedule": {
812
837
  const body = input;
838
+ const schedule = (made) => backend.sendsSchedulesHere === false ? screenSchedule(caller, made, signal) : backend.schedule(caller, made, signal);
813
839
  const allowed = engine && mentionsEmails(body.text) ? await mayPost(caller, body, signal) : null;
814
- if (!allowed) return backend.schedule(caller, body, signal);
840
+ if (!allowed) return schedule(body);
815
841
  const text = await withEmails(body.text);
816
- const answer = await backend.schedule(caller, {
842
+ const answer = await schedule({
817
843
  ...body,
818
844
  text
819
- }, signal);
845
+ });
820
846
  const { access } = allowed;
821
847
  return text !== body.text && access !== null && !access.readable(answer.schedule.channel) ? {
822
848
  ...answer,
@@ -0,0 +1,5 @@
1
+ import "./auth-CU56Q2Xh.mjs";
2
+ import "./folds-8wRRXjAt.mjs";
3
+ import "./client-CjAatpV4.mjs";
4
+ import "./backend-n3hajfrD.mjs";
5
+ import "./errors-87TVtXeb.mjs";