@vercel/board 0.16.0 → 0.18.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 (130) hide show
  1. package/README.md +27 -27
  2. package/dist/{a2-C41hD8SF.mjs → a2-ChRLRupa.mjs} +969 -25
  3. package/dist/access.d.mts +1 -1
  4. package/dist/access.mjs +3 -3
  5. package/dist/{actors-DjMeZpnU.mjs → actors-BOamkEuz.mjs} +3 -73
  6. package/dist/attempts-CLG840Oj.mjs +20 -0
  7. package/dist/{auth-COzc09sn.mjs → auth-Drg9FiC2.mjs} +225 -2
  8. package/dist/backend-B-SJ7p6p.d.mts +680 -0
  9. package/dist/backend-a2/contract.d.mts +873 -2
  10. package/dist/backend-a2/contract.mjs +2 -2
  11. package/dist/backend-a2/scheduler-local.d.mts +1 -1
  12. package/dist/backend-a2/scheduler-local.mjs +1 -1
  13. package/dist/backend-a2/scheduler-qstash.d.mts +78 -1
  14. package/dist/backend-a2/scheduler-qstash.mjs +521 -1
  15. package/dist/backend-a2/scheduler-vercel.d.mts +37 -1
  16. package/dist/backend-a2/scheduler-vercel.mjs +203 -1
  17. package/dist/backend-a2/store-memory.d.mts +2 -2
  18. package/dist/backend-a2/store-memory.mjs +3 -3
  19. package/dist/backend-a2/store-postgres.d.mts +1 -1
  20. package/dist/backend-a2/store-postgres.mjs +2850 -2
  21. package/dist/backend-a2/store-redis-http.d.mts +1 -1
  22. package/dist/backend-a2/store-redis-http.mjs +490 -2
  23. package/dist/backend-a2/store-redis.d.mts +10 -2
  24. package/dist/backend-a2/store-redis.mjs +51 -2
  25. package/dist/backend-a2/store-sqlite.d.mts +1 -1
  26. package/dist/backend-a2/store-sqlite.mjs +3 -3
  27. package/dist/backend-a2.d.mts +2706 -3
  28. package/dist/backend-a2.mjs +1 -1
  29. package/dist/{backend-http-Bid7RIpC.d.mts → backend-http-7cNb8nJI.d.mts} +10 -3
  30. package/dist/{backend-http-0roEmjl0.mjs → backend-http-CMlL3yq3.mjs} +2 -2
  31. package/dist/{backend-http-BVRno-ca.mjs → backend-http-Ddzd5kTS.mjs} +32 -3
  32. package/dist/backend-http.d.mts +1 -1
  33. package/dist/backend-http.mjs +1 -1
  34. package/dist/backend-http.node.d.mts +1 -1
  35. package/dist/backend-http.node.mjs +1 -1
  36. package/dist/{chat-provider-XCnQlxq6.d.mts → chat-provider-DNJQyajS.d.mts} +16 -4
  37. package/dist/{chat-provider-jFSeyNmB.mjs → chat-provider-YHy-WbrB.mjs} +15 -9
  38. package/dist/cli.mjs +30 -10
  39. package/dist/{client-DGDPaN7U.mjs → client-BA-J7usH.mjs} +10 -2
  40. package/dist/{conformance-DRR23YNy.mjs → conformance-CT5EeHeC.mjs} +5 -2
  41. package/dist/conformance.d.mts +1 -1
  42. package/dist/conformance.mjs +1 -1
  43. package/dist/{engine-Bir5s8ts.mjs → engine-D0oBUBEK.mjs} +249 -163
  44. package/dist/events-CewD4kw7.mjs +75 -0
  45. package/dist/{exports-Dvc1o52I.mjs → exports-CgjfMrQ-.mjs} +35 -32
  46. package/dist/exports-ZsgjzEnM.d.mts +4 -0
  47. package/dist/external-BV_cUncs.mjs +151 -0
  48. package/dist/folds-CRYY14kc.d.mts +534 -0
  49. package/dist/{reducers-mUPFuFJV.mjs → folds-Dyjput8M.mjs} +6 -73
  50. package/dist/ids-C6AYo1bu.mjs +31 -0
  51. package/dist/index.d.mts +4 -4
  52. package/dist/{index.default-Bkg77Pxn.mjs → index.default-C5Cpz3sa.mjs} +1 -1
  53. package/dist/index.mjs +5 -5
  54. package/dist/index.node.d.mts +4 -4
  55. package/dist/index.node.mjs +3 -3
  56. package/dist/inspect/app.js +31 -31
  57. package/dist/inspect/engine.js +5 -5
  58. package/dist/internal-CvsrprnU-CRY89I-b.mjs +203 -0
  59. package/dist/{local-Cr_dSjvs.mjs → local-DhkrAqPP.mjs} +1 -1
  60. package/dist/mentions-CB6ShY4R.mjs +72 -0
  61. package/dist/{targets-yM5JBUgr.mjs → middleware-CqaCwucP.mjs} +1 -33
  62. package/dist/platform-B4TnJtWu-CwcTyKg2.mjs +32 -0
  63. package/dist/{project-CFiArraX.mjs → project-DMDST9kl.mjs} +1 -1
  64. package/dist/{provider-BGjz-mDa.d.mts → provider-B0b9Qxw4.d.mts} +170 -40
  65. package/dist/provider-B3DpYSDq.mjs +220 -0
  66. package/dist/redis-tables-DN9PfIDi.mjs +3897 -0
  67. package/dist/reducer-CYPHsSmd-BQIfozGi.d.mts +170 -0
  68. package/dist/reducer-CjmvZSAb-D_OSISmj.mjs +74 -0
  69. package/dist/{contract-CuJmlrhw.mjs → reducers-BFmIXilu.mjs} +260 -287
  70. package/dist/retryable-lazy-DZWmHpii-Bye-W8q7.mjs +19 -0
  71. package/dist/scheduler-task-X1SToDjP-BVoWuInI.mjs +69 -0
  72. package/dist/{serve-BsOOc-6_.mjs → serve-BGYw0uNL.mjs} +5 -5
  73. package/dist/{server-jwPY7_wi.mjs → server-DPxBSogx.mjs} +4 -4
  74. package/dist/server-DtFrWxi3-Boa6gdWO.mjs +3602 -0
  75. package/dist/server-qgez0Xny-D5mLfqFQ.d.mts +385 -0
  76. package/dist/{serving-BNbzc96L.mjs → serving-CNkgUPW1.mjs} +1 -1
  77. package/dist/session-clear-wHd5GCpZ-BnKSxReK.mjs +51 -0
  78. package/dist/{sql-DbiEnqM0.mjs → sql-U9WTxrwJ.mjs} +2 -2
  79. package/dist/{sqlite-db-Cbj03vmp.mjs → sqlite-db-Ds4majtT.mjs} +1 -1
  80. package/dist/store-D0MhBvg6-4Z2L0Kqd.d.mts +530 -0
  81. package/dist/store-N8PXxDAS-BWBgzBHp.mjs +5 -0
  82. package/dist/store-codec-DwrWlgQx-CYxGW0aD.mjs +61 -0
  83. package/dist/store-memory-Bas9Uq5C.mjs +872 -0
  84. package/dist/store-memory-ZLiZbH3R.mjs +2 -0
  85. package/dist/store-polling-BZt2Rf3D-BiEM6_HB.mjs +148 -0
  86. package/dist/store-presence-polling-JuAy2Ck2-CYpuK75X.mjs +109 -0
  87. package/dist/store-sqlite-BmwL3pUR.mjs +2 -0
  88. package/dist/store-sqlite-CxxPCcCT.mjs +1053 -0
  89. package/dist/stream-buffer-DrTyiIuo-BKy-vI14.mjs +53 -0
  90. package/dist/sync-github.d.mts +24 -17
  91. package/dist/sync-github.mjs +89 -22
  92. package/dist/sync-kapso.d.mts +15 -13
  93. package/dist/sync-kapso.mjs +17 -14
  94. package/dist/sync-linear.d.mts +219 -0
  95. package/dist/sync-linear.mjs +311 -0
  96. package/dist/sync-slack.d.mts +12 -17
  97. package/dist/sync-slack.mjs +54 -42
  98. package/dist/sync.chat.d.mts +1 -1
  99. package/dist/sync.chat.mjs +1 -1
  100. package/dist/sync.d.mts +3 -3
  101. package/dist/sync.mjs +5 -4
  102. package/dist/targets-C-wdR5wP.mjs +33 -0
  103. package/dist/{tokens-DKLn5P9B.mjs → tokens-DhuzKzAl.mjs} +1 -1
  104. package/dist/{types-BHCjTZdB.d.mts → types-8yq2VnKI.d.mts} +2 -2
  105. package/dist/{users-Dls7_ANV.mjs → users-yryHKFMo.mjs} +2 -2
  106. package/dist/{watch-spec-W0c7ypHJ.mjs → watchers-hash-RLtnSF-t.mjs} +233 -28
  107. package/docs/01-board/01-mounting.md +1 -1
  108. package/docs/02-sync/01-overview.md +72 -48
  109. package/docs/02-sync/02-functions.md +398 -0
  110. package/docs/02-sync/03-slack.md +30 -25
  111. package/docs/02-sync/04-github.md +62 -26
  112. package/docs/02-sync/05-whatsapp.md +23 -19
  113. package/docs/02-sync/06-providers.md +60 -27
  114. package/docs/02-sync/07-linear.md +129 -0
  115. package/docs/03-guides/04-triage.md +1 -1
  116. package/docs/04-reference/01-cli.md +26 -6
  117. package/docs/04-reference/02-board.md +18 -5
  118. package/docs/04-reference/03-http.md +5 -2
  119. package/docs/04-reference/05-conformance.md +1 -1
  120. package/docs/index.md +5 -10
  121. package/package.json +31 -6
  122. package/dist/a2-D24cvwpN.d.mts +0 -3359
  123. package/dist/access-CJU2-vaa.mjs +0 -226
  124. package/dist/attempts-Bxp9KgPf.mjs +0 -919
  125. package/dist/exports-DLiE7fLY.d.mts +0 -4
  126. package/dist/provider-CuewcDNZ.mjs +0 -205
  127. package/dist/redis-tables-B35vv4CZ.mjs +0 -842
  128. package/dist/reducers-B_vUGQRp.d.mts +0 -1172
  129. package/dist/watchers-hash-CLjsa6od.mjs +0 -225
  130. package/docs/02-sync/02-callbacks.md +0 -265
@@ -1,8 +1,9 @@
1
1
  import { m as MESSAGE_TEXT_MAX } from "./schemas-Be08nyfW.mjs";
2
- import "./contract-CuJmlrhw.mjs";
3
- import { c as eventSchema, i as UnreadableError, s as envelope } from "./provider-CuewcDNZ.mjs";
4
- import { r as withUserTokens, t as connectUserTokens } from "./users-Dls7_ANV.mjs";
5
- import { r as markdownOf, t as ChatSyncProvider } from "./chat-provider-jFSeyNmB.mjs";
2
+ import "./external-BV_cUncs.mjs";
3
+ import { i as UnreadableError, s as channels } from "./provider-B3DpYSDq.mjs";
4
+ import { i as eventSchema, r as envelope } from "./events-CewD4kw7.mjs";
5
+ import { r as withUserTokens, t as connectUserTokens } from "./users-yryHKFMo.mjs";
6
+ import { r as markdownOf, t as ChatSyncProvider } from "./chat-provider-YHy-WbrB.mjs";
6
7
  import { z } from "zod";
7
8
  import { stringifyMarkdown } from "chat";
8
9
  import { createSlackAdapter } from "@chat-adapter/slack";
@@ -211,18 +212,35 @@ function slackFileName(filename, mimeType) {
211
212
  }
212
213
  const SlackEvent = eventSchema("slack", "message");
213
214
  /**
214
- * Syncs Slack channels with board channels. Keys are channels by name
215
- * (`'#prs'`) or ID (`'C0123ABC'`); values are a board channel (a two-way
216
- * mirror) or a callback that handles every incoming event. The app joins public
217
- * channels it isn't in; private ones need an invite.
215
+ * Syncs a Slack workspace with the board: `app` is a Vercel Connect connector
216
+ * (`'slack/acme'`) or the options, and `sync` is what each event does, both ways,
217
+ * like `channels({ '#prs': 'prs' })`. Invite the app to each channel it syncs:
218
+ * it never joins one on its own.
218
219
  */
219
- function slack(options) {
220
- return new SlackProvider(options);
220
+ function slack(app, sync) {
221
+ return new SlackProvider(typeof app === "string" ? { adapter: app } : app, sync);
221
222
  }
223
+ /**
224
+ * Slack's client retries 10 times over about 30 minutes by default, which outlives any function:
225
+ * here a call gets 3 more tries. Each still waits out Slack's `Retry-After` (about a minute at
226
+ * most), and `timeout` caps only the request itself, at 30 seconds.
227
+ */
228
+ const SLACK_CLIENT = {
229
+ retryConfig: {
230
+ retries: 3,
231
+ factor: 2,
232
+ minTimeout: 500,
233
+ randomize: true
234
+ },
235
+ timeout: 3e4
236
+ };
222
237
  /** A Slack adapter, client, and people's tokens, all from one Vercel Connect connector. */
223
238
  function fromConnector(connector) {
224
239
  const credentials = connectSlackAdapter(connector);
225
- const adapter = withUserTokens(createSlackAdapter(credentials), connectUserTokens(connector));
240
+ const adapter = withUserTokens(createSlackAdapter({
241
+ ...credentials,
242
+ webClientOptions: SLACK_CLIENT
243
+ }), connectUserTokens(connector));
226
244
  const botToken = credentials.botToken;
227
245
  const client = async () => adapter.withBotToken(typeof botToken === "function" ? await botToken() : botToken, () => adapter.webClient);
228
246
  return {
@@ -233,7 +251,7 @@ function fromConnector(connector) {
233
251
  var SlackProvider = class extends ChatSyncProvider {
234
252
  web;
235
253
  customize;
236
- constructor(given) {
254
+ constructor(given, sync) {
237
255
  const connected = typeof given.adapter === "string" ? fromConnector(given.adapter) : null;
238
256
  const options = connected ? {
239
257
  ...given,
@@ -300,26 +318,6 @@ var SlackProvider = class extends ChatSyncProvider {
300
318
  if (!found) throw new UnreadableError("channel_not_found", publicOnly ? `no public Slack channel named #${name} (the app lacks groups:read, so a private channel needs its ID)` : `no Slack channel named #${name} (a private channel shows up once the app is invited)`);
301
319
  return found;
302
320
  }
303
- const joined = /* @__PURE__ */ new Map();
304
- function join(client, id, known) {
305
- let promise = joined.get(id);
306
- if (!promise) {
307
- promise = (async () => {
308
- const channel = known ?? await client.conversations.info({ channel: id }).then((r) => r.channel, () => void 0);
309
- if (!channel || channel.is_member) return;
310
- const label = channel.name ? `#${channel.name}` : id;
311
- if (channel.is_private) return console.warn(`[board] slack: invite the app to ${label}; it can't join private channels itself`);
312
- try {
313
- await client.conversations.join({ channel: id });
314
- } catch (error) {
315
- joined.delete(id);
316
- console.warn(`[board] slack: couldn't join ${label} (${slackError(error)}); invite the app to it`);
317
- }
318
- })();
319
- joined.set(id, promise);
320
- }
321
- return promise;
322
- }
323
321
  const avatars = /* @__PURE__ */ new Map();
324
322
  function avatarOf(user) {
325
323
  let promise = avatars.get(user);
@@ -365,7 +363,6 @@ var SlackProvider = class extends ChatSyncProvider {
365
363
  fetchMetadata: rest
366
364
  };
367
365
  },
368
- render: options.render,
369
366
  platform: "slack",
370
367
  adapter: options.adapter,
371
368
  canStartThreads: true,
@@ -399,15 +396,11 @@ var SlackProvider = class extends ChatSyncProvider {
399
396
  },
400
397
  mention: (actor) => actor.id.startsWith("slack:user:") ? `<@${actor.id.slice(11)}>` : null,
401
398
  async resolveChannel(key) {
402
- if (!key.startsWith("#")) {
403
- const web = await client().catch(() => void 0);
404
- if (web) await join(web, key);
405
- return key;
406
- }
399
+ if (!key.startsWith("#")) return key;
407
400
  const web = await client();
408
401
  if (!web) throw new Error(`can't look up Slack channel ${key}: use its ID instead`);
409
402
  const channel = await find(web, key.slice(1));
410
- await join(web, channel.id, channel);
403
+ if (!channel.is_member) console.warn(`[board] slack: invite the app to ${key}; its events don't arrive until it's in`);
411
404
  return channel.id;
412
405
  },
413
406
  markdown(message) {
@@ -421,6 +414,26 @@ var SlackProvider = class extends ChatSyncProvider {
421
414
  const raw = message.raw;
422
415
  return slackLinks(key, typeof raw?.text === "string" ? raw.text : message.text, Array.isArray(raw?.attachments) ? raw.attachments : [], mrkdwn);
423
416
  },
417
+ locate(url) {
418
+ const found = permalinkOf(url.trim());
419
+ return found ? {
420
+ thread: `${found.channel}:${found.thread}`,
421
+ message: found.ts
422
+ } : null;
423
+ },
424
+ async url(thread, message) {
425
+ const [channel] = thread.split(":");
426
+ const web = await client();
427
+ if (!web?.chat?.getPermalink || !channel) return null;
428
+ try {
429
+ return (await web.chat.getPermalink({
430
+ channel,
431
+ message_ts: message
432
+ })).permalink ?? null;
433
+ } catch {
434
+ return null;
435
+ }
436
+ },
424
437
  async fetchLink(link) {
425
438
  const [, channel, thread] = link.thread.split(":");
426
439
  const web = await client();
@@ -614,8 +627,7 @@ var SlackProvider = class extends ChatSyncProvider {
614
627
  kind: at === -1 ? "channel" : "thread"
615
628
  };
616
629
  }
617
- });
618
- for (const [key, value] of Object.entries(options.channels)) this.map(key, value);
630
+ }, sync);
619
631
  this.web = client;
620
632
  }
621
633
  /**
@@ -725,4 +737,4 @@ var SlackProvider = class extends ChatSyncProvider {
725
737
  }
726
738
  };
727
739
  //#endregion
728
- export { SlackEvent, SlackProvider, forwardText, slack, slackFileName, slackIcon, slackLinks, withUserTokens, withoutHtml, withoutLabel };
740
+ export { SlackEvent, SlackProvider, channels, forwardText, slack, slackFileName, slackIcon, slackLinks, withUserTokens, withoutHtml, withoutLabel };
@@ -1,2 +1,2 @@
1
- import { i as externalAuthor, n as ChatSyncProvider, r as ChatTransport, t as ChatInput } from "./chat-provider-XCnQlxq6.mjs";
1
+ import { i as externalAuthor, n as ChatSyncProvider, r as ChatTransport, t as ChatInput } from "./chat-provider-DNJQyajS.mjs";
2
2
  export { ChatInput, ChatSyncProvider, ChatTransport, externalAuthor };
@@ -1,2 +1,2 @@
1
- import { n as externalAuthor, t as ChatSyncProvider } from "./chat-provider-jFSeyNmB.mjs";
1
+ import { n as externalAuthor, t as ChatSyncProvider } from "./chat-provider-YHy-WbrB.mjs";
2
2
  export { ChatSyncProvider, externalAuthor };
package/dist/sync.d.mts CHANGED
@@ -1,3 +1,3 @@
1
- import { _ as ExternalFile, g as ExternalEvent, h as ExternalAuthor } from "./reducers-B_vUGQRp.mjs";
2
- import { C as SyncResult, E as eventSchema, S as SyncOverrides, T as envelope, _ as connectUserTokens, a as ProviderFile, b as ProviderEvent, c as Receive, d as ResolvedTarget, f as SyncClient, g as UserTokens, h as SyncProvider, i as ProviderDelivery, l as ReceivedEvent, m as SyncHandler, o as ProviderOptions, p as SyncContext, r as FileDelivery, s as ProviderPost, t as EventMapping, u as RenderInput, v as withUserTokens, w as SyncRoot, x as RootOverride, y as EventBase } from "./provider-BGjz-mDa.mjs";
3
- export { EventBase, type EventMapping, type ExternalAuthor, ExternalEvent, type ExternalFile, type FileDelivery, type ProviderDelivery, type ProviderEvent, type ProviderFile, type ProviderOptions, type ProviderPost, type Receive, ReceivedEvent, type RenderInput, ResolvedTarget, type RootOverride, type SyncClient, type SyncContext, type SyncHandler, SyncOverrides, SyncProvider, type SyncResult, type SyncRoot, type UserTokens, connectUserTokens, envelope, eventSchema, withUserTokens };
1
+ import { d as ExternalFile, l as ExternalAuthor, u as ExternalEvent } from "./folds-CRYY14kc.mjs";
2
+ import { A as SyncOverrides, C as channels, D as EventBase, E as render, M as SyncRoot, N as envelope, O as ProviderEvent, P as eventSchema, S as SyncUnit, T as mirror, _ as withUserTokens, a as ProviderOptions, b as SyncIn, c as ReceivedEvent, d as ResolvedTarget, f as SyncContext, g as connectUserTokens, h as UserTokens, i as ProviderFile, j as SyncResult, k as RootOverride, l as RenderInput, m as UnreadableError, n as FileDelivery, o as ProviderPost, p as SyncProvider, r as ProviderDelivery, s as Receive, u as Renderer, v as Sync, w as into, x as SyncOut, y as SyncFn } from "./provider-B0b9Qxw4.mjs";
3
+ export { EventBase, type ExternalAuthor, ExternalEvent, type ExternalFile, type FileDelivery, type ProviderDelivery, type ProviderEvent, type ProviderFile, type ProviderOptions, type ProviderPost, type Receive, ReceivedEvent, type RenderInput, type Renderer, ResolvedTarget, type RootOverride, type Sync, type SyncContext, type SyncFn, type SyncIn, type SyncOut, SyncOverrides, SyncProvider, type SyncResult, type SyncRoot, type SyncUnit, UnreadableError, type UserTokens, channels, connectUserTokens, envelope, eventSchema, into, mirror, render, withUserTokens };
package/dist/sync.mjs CHANGED
@@ -1,4 +1,5 @@
1
- import { r as ExternalEvent } from "./contract-CuJmlrhw.mjs";
2
- import { a as EventBase, c as eventSchema, n as ResolvedTarget, o as SyncOverrides, r as SyncProvider, s as envelope, t as ReceivedEvent } from "./provider-CuewcDNZ.mjs";
3
- import { r as withUserTokens, t as connectUserTokens } from "./users-Dls7_ANV.mjs";
4
- export { EventBase, ExternalEvent, ReceivedEvent, ResolvedTarget, SyncOverrides, SyncProvider, connectUserTokens, envelope, eventSchema, withUserTokens };
1
+ import { n as ExternalEvent } from "./external-BV_cUncs.mjs";
2
+ import { c as into, i as UnreadableError, l as mirror, n as ResolvedTarget, r as SyncProvider, s as channels, t as ReceivedEvent, u as render } from "./provider-B3DpYSDq.mjs";
3
+ import { i as eventSchema, n as SyncOverrides, r as envelope, t as EventBase } from "./events-CewD4kw7.mjs";
4
+ import { r as withUserTokens, t as connectUserTokens } from "./users-yryHKFMo.mjs";
5
+ export { EventBase, ExternalEvent, ReceivedEvent, ResolvedTarget, SyncOverrides, SyncProvider, UnreadableError, channels, connectUserTokens, envelope, eventSchema, into, mirror, render, withUserTokens };
@@ -0,0 +1,33 @@
1
+ import { l as isThreadTarget } from "./external-BV_cUncs.mjs";
2
+ import { n as replaceMentions, t as extractMentions } from "./mentions-CB6ShY4R.mjs";
3
+ //#region src/server/targets.ts
4
+ /** A target as a post or a receipt names it, resolved: a plain one's kind goes by its shape. */
5
+ const syncTarget = (target) => typeof target === "string" ? {
6
+ target,
7
+ kind: isThreadTarget(target) ? "thread" : "channel"
8
+ } : target;
9
+ /** Whether `text` mentions anyone by email, `@[ada@vercel.com]`, which sync looks up. */
10
+ const mentionsEmails = (text) => extractMentions(text).some((token) => token.kind === "actor" && token.actor.includes("@"));
11
+ /**
12
+ * `@[ada@vercel.com]`: each email `find` knows (a synced platform's user) becomes that user's
13
+ * exact mention, `@[slack:user:U0456]`, so it reads natively there; the rest stay plain text.
14
+ */
15
+ async function withEmailMentions(text, find) {
16
+ const emails = extractMentions(text).flatMap((token) => token.kind === "actor" && token.actor.includes("@") ? [token.actor] : []);
17
+ if (emails.length === 0) return {
18
+ text,
19
+ found: []
20
+ };
21
+ const byEmail = /* @__PURE__ */ new Map();
22
+ for (const email of emails) byEmail.set(email, await find?.(email).catch(() => null) ?? null);
23
+ return {
24
+ text: replaceMentions(text, (token) => {
25
+ if (token.kind !== "actor" || !byEmail.has(token.actor)) return null;
26
+ const actor = byEmail.get(token.actor);
27
+ return actor ? `@[${actor.id}]` : token.actor;
28
+ }),
29
+ found: [...byEmail.values()].filter((actor) => actor !== null)
30
+ };
31
+ }
32
+ //#endregion
33
+ export { syncTarget as n, withEmailMentions as r, mentionsEmails as t };
@@ -1,5 +1,5 @@
1
1
  import { l as unauthorized } from "./errors-DX3hpwWt.mjs";
2
- import { f as markVerified, m as trustedIssuers, n as TOKENS, r as VERCEL_ISSUER, u as issuerName } from "./auth-COzc09sn.mjs";
2
+ import { f as markVerified, m as trustedIssuers, n as TOKENS, r as VERCEL_ISSUER, u as issuerName } from "./auth-Drg9FiC2.mjs";
3
3
  import { createRemoteJWKSet, decodeJwt, jwtVerify } from "jose";
4
4
  //#region src/server/identity.ts
5
5
  let fromDisk;
@@ -1,5 +1,5 @@
1
- import { a as ExternalCopy, c as SyncedThread, o as Message, p as BoardEvent } from "./reducers-B_vUGQRp.mjs";
2
- import { A2Store } from "experimental-a2/server";
1
+ import { a as ExternalCopy, c as SyncedThread, g as BoardEvent, o as Message } from "./folds-CRYY14kc.mjs";
2
+ import { t as A2Store } from "./store-D0MhBvg6-4Z2L0Kqd.mjs";
3
3
  //#region src/store/types.d.ts
4
4
  /**
5
5
  * What a query matches, resolved by the board: handles are already actor and
@@ -2,8 +2,8 @@
2
2
  const USER_TOKENS = Symbol.for("board.userTokens");
3
3
  /**
4
4
  * Gives an adapter people's tokens, for your own OAuth: `slack({ adapter:
5
- * withUserTokens(createSlackAdapter(...), { authorize, token }) })`. A Connect
6
- * connector name (`adapter: 'slack/acme'`) does this for you.
5
+ * withUserTokens(createSlackAdapter(...), { authorize, token }) }, sync)`. A
6
+ * Connect connector name (`slack('slack/acme', sync)`) does this for you.
7
7
  */
8
8
  function withUserTokens(adapter, tokens) {
9
9
  Object.defineProperty(adapter, USER_TOKENS, {
@@ -1,6 +1,7 @@
1
- import { n as DIRECT_UPLOAD_BYTES_MAX } from "./files-SniSmeEr.mjs";
1
+ import { n as DIRECT_UPLOAD_BYTES_MAX, t as ATTACHMENT_BYTES_MAX } from "./files-SniSmeEr.mjs";
2
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-Be08nyfW.mjs";
3
- import { a as ExternalTarget, c as Link, l as Origin, n as ExternalAuthor, r as ExternalEvent } from "./contract-CuJmlrhw.mjs";
3
+ import { a as Link, i as ExternalTarget, n as ExternalEvent, o as Origin, t as ExternalAuthor } from "./external-BV_cUncs.mjs";
4
+ import { r as badRequest, t as HttpError } from "./errors-DX3hpwWt.mjs";
4
5
  import { z } from "zod";
5
6
  //#region src/api/routes.ts
6
7
  /**
@@ -150,6 +151,8 @@ const SourceReadQuery = z.object({
150
151
  target: z.string().min(1).max(4e3),
151
152
  limit: z.coerce.number().int().min(1).max(200).optional()
152
153
  });
154
+ /** A platform's link to a message or thread, such as a Slack permalink or a GitHub comment's URL. */
155
+ const LinkedQuery = z.object({ url: z.string().url().max(4e3) });
153
156
  const IdentityUnlinkBody = z.object({ actor: z.string().min(1).max(256) });
154
157
  /** A sync entry's key: `slack`, `github`. */
155
158
  const SyncKey = z.string().min(1).max(64);
@@ -414,6 +417,17 @@ const routes = {
414
417
  path: "/messages/:id/sync",
415
418
  body: SyncBody
416
419
  },
420
+ /** Where the message's thread is synced, and its copy on each target: needs `read` where it is. */
421
+ synced: {
422
+ method: "GET",
423
+ path: "/messages/:id/sync"
424
+ },
425
+ /** The board message a platform link names, from what the board synced: 404 `message_not_found` for one it didn't, or that the caller can't read. */
426
+ linked: {
427
+ method: "GET",
428
+ path: "/links",
429
+ query: LinkedQuery
430
+ },
417
431
  /** Ephemeral: shows typing in the thread's synced platform threads, and writes nothing to the log. Needs `post` where the thread is. */
418
432
  typing: {
419
433
  method: "POST",
@@ -612,35 +626,205 @@ const routes = {
612
626
  }
613
627
  };
614
628
  //#endregion
615
- //#region src/client/ids.ts
616
- const hex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
617
- /** SHA-256 of `text`, in hex. */
618
- async function sha256(text) {
619
- return hex(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text))));
629
+ //#region src/server/backend.ts
630
+ /** The subscription sync's outbound runs on, in `BoardBinding.subscriptions`. No watcher's ID starts with `@`. */
631
+ const SYNC = "@sync";
632
+ //#endregion
633
+ //#region src/version.ts
634
+ const VERSION = "0.18.0";
635
+ //#endregion
636
+ //#region src/server/routing.ts
637
+ /** board()'s own routes: the files it serves, and sync's, which run where board() does. */
638
+ const BOARD_ROUTES = [
639
+ "avatar",
640
+ "openapi",
641
+ "webhook",
642
+ "signIn",
643
+ "signedIn",
644
+ "identityUnlink",
645
+ "sync"
646
+ ];
647
+ /** Every protocol method, by the route it's served at. */
648
+ const METHODS = [
649
+ "discovery",
650
+ "me",
651
+ "channels",
652
+ "actors",
653
+ "identity",
654
+ "messages",
655
+ "threads",
656
+ "message",
657
+ "post",
658
+ "posted",
659
+ "edit",
660
+ "delete",
661
+ "react",
662
+ "label",
663
+ "schedule",
664
+ "schedules",
665
+ "cancelSchedule",
666
+ "upload",
667
+ "attachment",
668
+ "uploadStart",
669
+ "uploadFinish",
670
+ "watchers",
671
+ "register",
672
+ "matches",
673
+ "ack",
674
+ "events",
675
+ "syncImport",
676
+ "syncReceipt",
677
+ "syncThread",
678
+ "syncFind",
679
+ "syncCopies",
680
+ "syncRoots",
681
+ "inboxPush",
682
+ "inboxNext",
683
+ "inboxAck",
684
+ "inbox",
685
+ "syncCode",
686
+ "syncAccount",
687
+ "syncUnlink",
688
+ "typing",
689
+ "readSource",
690
+ "synced",
691
+ "linked"
692
+ ];
693
+ const escape = (literal) => literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
694
+ function compile(method, path, target, own) {
695
+ const parts = path.split("/").filter(Boolean);
696
+ const source = parts.map((part) => part.startsWith(":") ? "/([^/]+)" : `/${escape(part)}`).join("");
697
+ return {
698
+ method,
699
+ pattern: new RegExp(`${source}$`),
700
+ names: parts.filter((part) => part.startsWith(":")).map((part) => part.slice(1)),
701
+ target,
702
+ segments: parts.length,
703
+ literals: parts.filter((part) => !part.startsWith(":")).length,
704
+ own
705
+ };
620
706
  }
621
707
  /**
622
- * A UUIDv8 from `parts`, exactly as A2's `idempotentId` makes one: the IDs a board derives for the
623
- * writes its own code makes, which no request may post with.
708
+ * The routes a board answers, most specific first: each is matched as a suffix of the request's
709
+ * path, so `/api/board/messages/12` is `GET /messages/:id` under `/api/board`. A backend's own
710
+ * route at a protocol path takes its place. Discovery is the mount itself.
624
711
  */
625
- async function derivedId(...parts) {
626
- const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(parts)))).slice(0, 16);
627
- bytes[6] = bytes[6] & 15 | 128;
628
- bytes[8] = bytes[8] & 63 | 128;
629
- const text = hex(bytes);
630
- return `${text.slice(0, 8)}-${text.slice(8, 12)}-${text.slice(12, 16)}-${text.slice(16, 20)}-${text.slice(20, 32)}`;
712
+ function createRouter(backendRoutes, basePath) {
713
+ const patterns = [
714
+ ...BOARD_ROUTES.map((name) => compile(routes[name].method, routes[name].path, {
715
+ kind: "board",
716
+ name
717
+ }, false)),
718
+ ...METHODS.filter((name) => name !== "discovery").map((name) => compile(routes[name].method, routes[name].path, {
719
+ kind: "protocol",
720
+ name
721
+ }, false)),
722
+ ...backendRoutes.map((route) => compile(route.method, route.path, {
723
+ kind: "backend",
724
+ route
725
+ }, true))
726
+ ].sort((a, b) => b.segments - a.segments || b.literals - a.literals || Number(b.own) - Number(a.own));
727
+ /** A segment that isn't URI-encoded names nothing: no route. */
728
+ const found = (candidate, base, values) => {
729
+ const params = {};
730
+ try {
731
+ candidate.names.forEach((name, i) => params[name] = decodeURIComponent(values[i]));
732
+ } catch {
733
+ return null;
734
+ }
735
+ const first = candidate.names[0];
736
+ return {
737
+ target: candidate.target,
738
+ base,
739
+ param: first === void 0 ? null : params[first],
740
+ params
741
+ };
742
+ };
743
+ const discovery = (base) => ({
744
+ target: {
745
+ kind: "protocol",
746
+ name: "discovery"
747
+ },
748
+ base,
749
+ param: null,
750
+ params: {},
751
+ root: true
752
+ });
753
+ return function route(method, pathname) {
754
+ const path = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
755
+ if (basePath !== void 0) {
756
+ const base = basePath.replace(/\/+$/, "");
757
+ if (path !== base && !path.startsWith(`${base}/`)) return null;
758
+ const rest = path.slice(base.length) || "/";
759
+ if (rest === "/") return method === "GET" ? discovery(base) : null;
760
+ for (const candidate of patterns) {
761
+ const hit = candidate.method === method ? rest.match(new RegExp(`^${candidate.pattern.source}`)) : null;
762
+ if (hit) return found(candidate, base, hit.slice(1));
763
+ }
764
+ return null;
765
+ }
766
+ for (const candidate of patterns) {
767
+ if (candidate.method !== method) continue;
768
+ const hit = path.match(candidate.pattern);
769
+ if (hit) return found(candidate, path.slice(0, hit.index), hit.slice(1));
770
+ }
771
+ return method === "GET" ? discovery(path === "/" ? "" : path) : null;
772
+ };
773
+ }
774
+ function queryObject(url) {
775
+ const out = {};
776
+ for (const key of new Set(url.searchParams.keys())) {
777
+ const values = url.searchParams.getAll(key);
778
+ out[key] = values.length > 1 ? values : values[0];
779
+ }
780
+ return out;
781
+ }
782
+ async function readJson(request) {
783
+ const text = await request.text();
784
+ if (text.trim() === "") return void 0;
785
+ try {
786
+ return JSON.parse(text);
787
+ } catch {
788
+ throw badRequest("request body is not valid JSON");
789
+ }
790
+ }
791
+ const tooLarge = () => new HttpError(413, "too_large", `attachments are at most ${ATTACHMENT_BYTES_MAX / 1024 / 1024} MB`);
792
+ /** The body, refused as soon as it's past `max`: with or without a `content-length`, it never reads more. */
793
+ async function readCapped(request, max) {
794
+ if (Number(request.headers.get("content-length") ?? 0) > max) throw tooLarge();
795
+ const chunks = [];
796
+ let size = 0;
797
+ for await (const chunk of request.body ?? []) {
798
+ size += chunk.byteLength;
799
+ if (size > max) throw tooLarge();
800
+ chunks.push(chunk);
801
+ }
802
+ const body = new Uint8Array(size);
803
+ let at = 0;
804
+ for (const chunk of chunks) {
805
+ body.set(chunk, at);
806
+ at += chunk.byteLength;
807
+ }
808
+ return body;
809
+ }
810
+ function openapi() {
811
+ const paths = {};
812
+ for (const [name, definition] of Object.entries(routes)) {
813
+ const path = definition.path.replace(/:(\w+)/g, "{$1}");
814
+ const operation = { operationId: name };
815
+ if ("query" in definition) operation["x-query-schema"] = z.toJSONSchema(definition.query, { io: "input" });
816
+ if ("body" in definition) operation.requestBody = { content: { "application/json": { schema: z.toJSONSchema(definition.body, { io: "input" }) } } };
817
+ (paths[path] ??= {})[definition.method.toLowerCase()] = operation;
818
+ }
819
+ return {
820
+ openapi: "3.1.0",
821
+ info: {
822
+ title: "board",
823
+ version: VERSION
824
+ },
825
+ paths
826
+ };
631
827
  }
632
- /** A derived ID's shape. */
633
- const DERIVED_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-8[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
634
- /**
635
- * A post's ID, from one derived for it, under `principal`: a board takes a `key:<principal>:` ID
636
- * only from that principal, so no one else can post with it first.
637
- */
638
- const keyId = async (principal, derived) => `key:${principal}:${(await sha256(`board-key\u001f${derived}`)).slice(0, 32)}`;
639
- /**
640
- * Whether `id` is exactly the shape `keyId` makes for `principal`. A prefix check isn't enough:
641
- * a principal's ID can be the start of another's (`oidc:gitlab.com:acme` and `oidc:gitlab.com:acme:ci`).
642
- */
643
- const isKeyIdOf = (id, principal) => id.startsWith(`key:${principal}:`) && /^[0-9a-f]{32}$/.test(id.slice(`key:${principal}:`.length));
644
828
  //#endregion
645
829
  //#region src/api/tail.ts
646
830
  /** The filter flags `read`, `tail`, and `wait` share, and that a watcher's `tail` accepts. */
@@ -784,4 +968,25 @@ function compileWatcher(id, spec) {
784
968
  };
785
969
  }
786
970
  //#endregion
787
- export { SyncBody as A, UploadStartBody as B, Ref as C, SignInQuery as D, SchedulesQuery as E, SyncReceiptBody as F, SyncRootsQuery as I, SyncUnlinkBody as L, SyncCopiesQuery as M, SyncFindQuery as N, SourceReadQuery as O, SyncImportBody as P, ThreadsQuery as R, ReactBody as S, ScheduleBody as T, routes as V, InboxQuery as _, DERIVED_ID as a, MessagesQuery as b, keyId as c, EditBody as d, EventsQuery as f, InboxPushBody as g, InboxNextQuery as h, withMentions as i, SyncCodeBody as j, SyncAccountBody as k, sha256 as l, InboxAckBody as m, hasCriteria as n, derivedId as o, IdentityUnlinkBody as p, FILTER_FLAGS as r, isKeyIdOf as s, compileWatcher as t, AckBody as u, LabelBody as v, RegisterBody as w, PostBody as x, MatchesQuery as y, TypingBody as z };
971
+ //#region src/server/watchers-hash.ts
972
+ /** By UTF-16 code unit, as `sort()` orders strings: the same in every locale and runtime. */
973
+ const byCodeUnit = (a, b) => a < b ? -1 : a > b ? 1 : 0;
974
+ /**
975
+ * What `watchersHash` hashes, the same on both ends: a board on `http()` hashes its watchers, and
976
+ * whether its sync subscribes, and the board it uses hashes what it holds.
977
+ */
978
+ function canonicalWatchers(watch, sync = []) {
979
+ const canonical = Object.keys(watch).sort(byCodeUnit).map((id) => [
980
+ id,
981
+ watch[id].tail,
982
+ watch[id].when
983
+ ]);
984
+ if (sync.length > 0) canonical.push(["sync", [...sync].sort((a, b) => byCodeUnit(a.key, b.key)).map((entry) => [
985
+ entry.key,
986
+ entry.platform,
987
+ entry.signIn
988
+ ])]);
989
+ return JSON.stringify(canonical);
990
+ }
991
+ //#endregion
992
+ export { ScheduleBody as A, SyncReceiptBody as B, LinkedQuery as C, ReactBody as D, PostBody as E, SyncBody as F, UploadStartBody as G, SyncUnlinkBody as H, SyncCodeBody as I, routes as K, SyncCopiesQuery as L, SignInQuery as M, SourceReadQuery as N, Ref as O, SyncAccountBody as P, SyncFindQuery as R, LabelBody as S, MessagesQuery as T, ThreadsQuery as U, SyncRootsQuery as V, TypingBody as W, IdentityUnlinkBody as _, withMentions as a, InboxPushBody as b, openapi as c, readJson as d, VERSION as f, EventsQuery as g, EditBody as h, FILTER_FLAGS as i, SchedulesQuery as j, RegisterBody as k, queryObject as l, AckBody as m, compileWatcher as n, METHODS as o, SYNC as p, hasCriteria as r, createRouter as s, canonicalWatchers as t, readCapped as u, InboxAckBody as v, MatchesQuery as w, InboxQuery as x, InboxNextQuery as y, SyncImportBody as z };
@@ -102,7 +102,7 @@ any backend:
102
102
  | `middleware` | Runs on every post and edit before it reaches the backend, whatever it came from. See [Middleware](./08-middleware.md) |
103
103
  | `project` | `{ id, teamId? }`: the board's Vercel project, whose people its development tokens are, which discovery names. Defaults to `VERCEL_PROJECT_ID`, then `.vercel/project.json`. The team scopes `vercelProject()` rules named without one |
104
104
  | `watch` | Tails the board runs for you, keyed by id: `{ triage: { tail, run } }`. See [Watchers](./06-watchers.md) |
105
- | `sync` | Two-way sync, keyed by a name you choose: `{ slack: slack({…}) }`. See [Sync](../02-sync/01-overview.md) |
105
+ | `sync` | Two-way sync, keyed by a name you choose: `{ slack: slack('slack/acme', channels({…})) }`. See [Sync](../02-sync/01-overview.md) |
106
106
  | `url` | The board's public URL, which every token's audience must equal. Inferred from requests otherwise |
107
107
  | `basePath` | The mount path, e.g. `/api/board`. Inferred from requests otherwise: a request can only make it look deeper than it is (`/api/board/x/messages/1`), so the board keeps the shallowest it's seen |
108
108
  | `streamMaxMs` | How long an SSE stream stays open before the board closes it and the client reconnects. Default `280000` |