@vercel/board 0.28.0 → 0.29.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 (97) hide show
  1. package/dist/{a2-B0_VlbtC.mjs → a2-HUbQJ0RD.mjs} +16 -192
  2. package/dist/access.d.mts +1 -1
  3. package/dist/access.mjs +3 -3
  4. package/dist/{actors-BLXVp4oZ.mjs → actors-DcJhFbwJ.mjs} +2 -2
  5. package/dist/{auth-lTkOgvcE.mjs → auth-JO22iIwr.mjs} +1 -1
  6. package/dist/{backend-ZKYTe3xa.d.mts → backend-CPC_vyOo.d.mts} +5 -5
  7. package/dist/backend-a2/blob-vercel.d.mts +1 -1
  8. package/dist/backend-a2/contract.d.mts +1 -1
  9. package/dist/backend-a2/contract.mjs +3 -3
  10. package/dist/backend-a2/store-memory.d.mts +1 -1
  11. package/dist/backend-a2/store-memory.mjs +2 -2
  12. package/dist/backend-a2/store-postgres.d.mts +1 -1
  13. package/dist/backend-a2/store-postgres.mjs +1 -1
  14. package/dist/backend-a2/store-redis-http.d.mts +1 -1
  15. package/dist/backend-a2/store-redis-http.mjs +1 -1
  16. package/dist/backend-a2/store-redis.d.mts +1 -1
  17. package/dist/backend-a2/store-redis.mjs +1 -1
  18. package/dist/backend-a2/store-sqlite.d.mts +1 -1
  19. package/dist/backend-a2/store-sqlite.mjs +2 -2
  20. package/dist/backend-a2.d.mts +8 -5
  21. package/dist/backend-a2.mjs +2 -1
  22. package/dist/{backend-http-BvNFrvcY.mjs → backend-http-Bmp0IgD3.mjs} +1 -1
  23. package/dist/{backend-http-E7YU0qhn.d.mts → backend-http-CqfKhrnn.d.mts} +5 -3
  24. package/dist/{backend-http-CfLVYqmj.mjs → backend-http-CzIJMm58.mjs} +3 -3
  25. package/dist/backend-http.d.mts +1 -1
  26. package/dist/backend-http.mjs +1 -1
  27. package/dist/backend-http.node.d.mts +1 -1
  28. package/dist/backend-http.node.mjs +1 -1
  29. package/dist/{backend-vercel-CIRObA0t.d.mts → backend-vercel-4tvcPKqX.d.mts} +7 -3
  30. package/dist/{backend-vercel-D87pIp2k.mjs → backend-vercel-BNauAs7w.mjs} +68 -10
  31. package/dist/{backend-vercel-DTrDiSk5.mjs → backend-vercel-DQc_TaHp.mjs} +1 -1
  32. package/dist/backend-vercel.d.mts +1 -1
  33. package/dist/backend-vercel.mjs +1 -1
  34. package/dist/backend-vercel.node.d.mts +1 -1
  35. package/dist/backend-vercel.node.mjs +1 -1
  36. package/dist/blob-DdbpJqN1.mjs +182 -0
  37. package/dist/{blob-B-9AvYis.d.mts → blob-DmiQjrYy.d.mts} +2 -2
  38. package/dist/{chat-provider-DsNEBQiA.d.mts → chat-provider-BWHbChcQ.d.mts} +5 -3
  39. package/dist/{chat-provider-DnHibqnM.mjs → chat-provider-COtsU5jB.mjs} +2 -2
  40. package/dist/cli-commands.mjs +1 -1
  41. package/dist/cli-embedded.d.mts +2 -2
  42. package/dist/cli-embedded.mjs +1 -1
  43. package/dist/cli.mjs +4 -4
  44. package/dist/{client-xLxeDz0f.d.mts → client-BqAjk0Q4.d.mts} +8 -1
  45. package/dist/{commands-BFookFtn.mjs → commands-C3R1AWgm.mjs} +4 -1
  46. package/dist/conformance.d.mts +1 -1
  47. package/dist/{engine-Bfe6pcXb.mjs → engine-DQxXk7dX.mjs} +20 -12
  48. package/dist/{errors-CB4tnZGz.d.mts → errors-DDktVeUa.d.mts} +1 -1
  49. package/dist/{events-CewD4kw7.mjs → events-qf5XG5VQ.mjs} +2 -2
  50. package/dist/{exports-CCzw2Vdw.mjs → exports-BG-0Wyn-.mjs} +9 -9
  51. package/dist/exports-mhAYiU4i.d.mts +5 -0
  52. package/dist/{external-BV_cUncs.mjs → external-D3p5T57R.mjs} +4 -2
  53. package/dist/{folds-Dyjput8M.mjs → folds-BDMSxBUf.mjs} +2 -2
  54. package/dist/{folds-jS9wlh9_.d.mts → folds-ZkHM4M-v.d.mts} +2 -0
  55. package/dist/{index-C05it0mC.d.mts → index-D3VvNfh_.d.mts} +2 -2
  56. package/dist/index.d.mts +5 -5
  57. package/dist/{index.default-DmKxH_E6.mjs → index.default-rB8sJ9RX.mjs} +1 -1
  58. package/dist/index.mjs +4 -4
  59. package/dist/index.node.d.mts +5 -5
  60. package/dist/index.node.mjs +2 -2
  61. package/dist/inspect/app.js +3 -3
  62. package/dist/inspect/engine.js +5 -5
  63. package/dist/{main-eci7w-aP.mjs → main-CVBqe3VO.mjs} +11 -8
  64. package/dist/{middleware-ChKM1zuu.mjs → middleware-CXjEeef4.mjs} +1 -1
  65. package/dist/{project-DMDST9kl.mjs → project-CBXlLftU.mjs} +1 -1
  66. package/dist/{provider-poIkXapd.mjs → provider-BaVmi4Hi.mjs} +3 -1
  67. package/dist/{provider-Cdg1qadh.d.mts → provider-DmSTp9lI.d.mts} +9 -2
  68. package/dist/{redis-tables-DN9PfIDi.mjs → redis-tables-BJQVUspu.mjs} +2 -2
  69. package/dist/{reducers-BcmSs4qf.mjs → reducers-DJzVpXwM.mjs} +3 -3
  70. package/dist/{schemas-Be08nyfW.mjs → schemas-DBmUO6pr.mjs} +2 -1
  71. package/dist/{serve-B09an9DQ.mjs → serve-G9RuaDSr.mjs} +4 -3
  72. package/dist/{server-DTX0prGr.mjs → server-BixV_byP.mjs} +4 -4
  73. package/dist/{serving-CvOWEym2.mjs → serving-B75Y4BF-.mjs} +1 -1
  74. package/dist/{sql-U9WTxrwJ.mjs → sql-D4Z_efJX.mjs} +2 -2
  75. package/dist/{sqlite-db-Ds4majtT.mjs → sqlite-db-Bz2JkfCU.mjs} +1 -1
  76. package/dist/sync-github.d.mts +4 -2
  77. package/dist/sync-github.mjs +4 -14
  78. package/dist/sync-kapso.d.mts +3 -2
  79. package/dist/sync-kapso.mjs +3 -3
  80. package/dist/sync-linear.d.mts +12 -2
  81. package/dist/sync-linear.mjs +189 -9
  82. package/dist/sync-slack.d.mts +41 -5
  83. package/dist/sync-slack.mjs +70 -11
  84. package/dist/sync.chat.d.mts +1 -1
  85. package/dist/sync.chat.mjs +1 -1
  86. package/dist/sync.d.mts +2 -2
  87. package/dist/sync.mjs +3 -3
  88. package/dist/tags-BpFo6q0_.mjs +14 -0
  89. package/dist/{targets-C-wdR5wP.mjs → targets-Cgjs29T0.mjs} +1 -1
  90. package/dist/{tokens-BuCU-61R.mjs → tokens-CC3izSNr.mjs} +1 -1
  91. package/dist/{types-aCAf6lGU.d.mts → types-No0WdhE5.d.mts} +1 -1
  92. package/dist/{watchers-hash-7DJU9b-n.mjs → watchers-hash-BDhLVkV5.mjs} +7 -5
  93. package/docs/02-sync/03-slack.md +6 -2
  94. package/docs/02-sync/07-linear.md +28 -6
  95. package/docs/04-reference/01-cli.md +4 -2
  96. package/package.json +1 -1
  97. package/dist/exports-D22M68rk.d.mts +0 -5
@@ -1,8 +1,10 @@
1
- import { _ as RemoteWatcherId } from "./schemas-Be08nyfW.mjs";
2
- import { O as RegisterBody, a as METHODS, d as VERSION, n as compileWatcher, t as canonicalWatchers } from "./watchers-hash-7DJU9b-n.mjs";
1
+ import { r as IMPORT_BYTES_MAX } from "./files-SniSmeEr.mjs";
2
+ import { _ as RemoteWatcherId } from "./schemas-DBmUO6pr.mjs";
3
+ import { O as RegisterBody, a as METHODS, d as VERSION, n as compileWatcher, t as canonicalWatchers } from "./watchers-hash-BDhLVkV5.mjs";
3
4
  import { o as sha256, r as derivedId } from "./wait-until-Dw4XrIDQ.mjs";
4
5
  import { a as forbidden, o as json, s as notFound, t as HttpError } from "./errors-CusN4x2l.mjs";
5
- import { a as wakePath, i as registrant, n as callerProfile, t as PROJECTS_OWN } from "./backend-http-CfLVYqmj.mjs";
6
+ import { a as wakePath, i as registrant, n as callerProfile, t as PROJECTS_OWN } from "./backend-http-CzIJMm58.mjs";
7
+ import { o as mediaType, r as fileName } from "./blob-DdbpJqN1.mjs";
6
8
  //#region src/server/backend-vercel.ts
7
9
  const PROFILE = /^[A-Za-z][A-Za-z0-9-]{0,31}$/;
8
10
  const SESSION = /^[A-Za-z0-9_.:-]{1,128}$/;
@@ -22,9 +24,16 @@ const UNKEPT = {
22
24
  schedule: ["no_scheduler", "Vercel's boards don't schedule messages yet"],
23
25
  schedules: ["no_scheduler", "Vercel's boards don't schedule messages yet"],
24
26
  cancelSchedule: ["no_scheduler", "Vercel's boards don't schedule messages yet"],
25
- upload: ["no_files", "Vercel's boards keep no files yet"],
26
- attachment: ["no_files", "Vercel's boards keep no files yet"]
27
+ uploadStart: ["no_direct_uploads", "Vercel's boards take files up to 4 MB in one upload for now"],
28
+ uploadFinish: ["no_direct_uploads", "Vercel's boards take files up to 4 MB in one upload for now"]
27
29
  };
30
+ /** A file as the board names it. */
31
+ const fileOf = ({ id, name, type, size }) => ({
32
+ id,
33
+ name,
34
+ type,
35
+ size
36
+ });
28
37
  const notSupported = (what) => new HttpError(501, "not_supported", `Vercel's boards don't support ${what} yet`);
29
38
  /** What a caller board() verified may do. Null for this deployment's own code, which may do what its credentials may. */
30
39
  const accessOf = (caller) => "identity" in caller ? caller.identity.principal.access : null;
@@ -89,6 +98,7 @@ function createVercel(options, environment) {
89
98
  if (!/^https:\/\/[^/]+$|^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) throw new Error(`vercel: ${origin} isn't Vercel's API: pass an https origin`);
90
99
  if (!options.fetch && !environment) throw new Error("vercel: pass a fetch that sends credentials: outside Node there's no environment to find them in");
91
100
  const doFetch = options.fetch ?? environment.fetch();
101
+ const filesFetch = options.files ?? fetch;
92
102
  let binding = null;
93
103
  /** This board's own code's names, which a caller here never takes as its profile. */
94
104
  const own = /* @__PURE__ */ new Set();
@@ -318,7 +328,7 @@ function createVercel(options, environment) {
318
328
  editedAt: message.editedAt === void 0 ? null : iso(message.editedAt),
319
329
  deleted: message.deletedAt !== void 0,
320
330
  undelivered: {},
321
- attachments: [],
331
+ attachments: message.attachments ?? [],
322
332
  links: [],
323
333
  version: message.version,
324
334
  author: await nameOf(who, message.createdBy)
@@ -560,7 +570,7 @@ function createVercel(options, environment) {
560
570
  by,
561
571
  origin: null,
562
572
  from: null,
563
- attachments: [],
573
+ attachments: message.attachments,
564
574
  links: []
565
575
  };
566
576
  break;
@@ -1144,12 +1154,58 @@ function createVercel(options, environment) {
1144
1154
  })
1145
1155
  };
1146
1156
  },
1157
+ async upload(caller, { data, name, type }) {
1158
+ checkCaller("upload", caller);
1159
+ if (data.byteLength === 0) throw new HttpError(400, "bad_request", "the file is empty");
1160
+ if (data.byteLength > 26214400) throw new HttpError(413, "too_large", `attachments are at most ${IMPORT_BYTES_MAX / 1024 / 1024} MB`);
1161
+ const who = whoOf(caller);
1162
+ const bytes = new Uint8Array(data);
1163
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
1164
+ const started = await api(who, "POST", await onBoard(who, "/attachments"), { body: {
1165
+ operationId: operationId(),
1166
+ name: fileName(name),
1167
+ type: mediaType(type),
1168
+ size: data.byteLength,
1169
+ sha256: btoa(String.fromCharCode(...digest))
1170
+ } });
1171
+ const put = await filesFetch(started.upload.url, {
1172
+ method: started.upload.method,
1173
+ headers: started.upload.headers,
1174
+ body: bytes,
1175
+ redirect: "error"
1176
+ });
1177
+ if (!put.ok) throw new HttpError(502, "upload_failed", `the upload to Vercel's storage answered ${put.status}`);
1178
+ const { attachment } = await api(who, "POST", await onBoard(who, `/attachments/${encodeURIComponent(started.attachment.id)}/complete`));
1179
+ return { attachment: fileOf(attachment) };
1180
+ },
1181
+ async attachment(caller, { id }, signal) {
1182
+ const who = whoOf(caller);
1183
+ const access = accessOf(caller);
1184
+ const found = await api(who, "GET", await onBoard(who, `/attachments/${encodeURIComponent(id)}`), signal ? { signal } : {});
1185
+ if (access) {
1186
+ if (!found.attachment.messageId) throw notFound(`no attachment: ${id}`, "attachment_not_found");
1187
+ const { message } = await readableMessage(who, access, found.attachment.messageId, signal).catch((error) => {
1188
+ if (error instanceof HttpError && error.status === 404) throw notFound(`no attachment: ${id}`, "attachment_not_found");
1189
+ throw error;
1190
+ });
1191
+ if (message.deletedAt !== void 0) throw notFound(`no attachment: ${id}`, "attachment_not_found");
1192
+ }
1193
+ const response = await filesFetch(found.url, {
1194
+ redirect: "error",
1195
+ ...signal ? { signal } : {}
1196
+ });
1197
+ if (!response.ok || !response.body) throw new HttpError(502, "download_failed", `Vercel's storage answered ${response.status}`);
1198
+ return {
1199
+ attachment: fileOf(found.attachment),
1200
+ data: response.body
1201
+ };
1202
+ },
1147
1203
  async post(caller, input, signal) {
1148
1204
  checkCaller("post", caller);
1149
- if (input.attachments?.length) throw new HttpError(501, "no_files", "Vercel's boards keep no files yet");
1150
1205
  if (input.sync?.length) throw notSupported("sync");
1151
1206
  const who = whoOf(caller);
1152
1207
  const access = accessOf(caller);
1208
+ if (access && input.attachments?.length) throw forbidden("this board's callers act as its project on Vercel's board, which can't tell them apart: only the project's own code posts files there");
1153
1209
  const labeled = Object.keys(input.labels ?? {}).length > 0;
1154
1210
  const clientId = input.id !== void 0 ? await postId(caller, input.id) : void 0;
1155
1211
  let made;
@@ -1163,7 +1219,8 @@ function createVercel(options, environment) {
1163
1219
  channelId: channel.id,
1164
1220
  text: input.text,
1165
1221
  ...input.labels && Object.keys(input.labels).length ? { labels: input.labels } : {},
1166
- ...clientId ? { clientId } : {}
1222
+ ...clientId ? { clientId } : {},
1223
+ ...input.attachments?.length ? { attachments: input.attachments } : {}
1167
1224
  },
1168
1225
  signal
1169
1226
  });
@@ -1176,7 +1233,8 @@ function createVercel(options, environment) {
1176
1233
  operationId: operationId(),
1177
1234
  text: input.text,
1178
1235
  ...to.sequence > 1 ? { replyTo: to.sequence } : {},
1179
- ...clientId ? { clientId } : {}
1236
+ ...clientId ? { clientId } : {},
1237
+ ...input.attachments?.length ? { attachments: input.attachments } : {}
1180
1238
  },
1181
1239
  signal
1182
1240
  });
@@ -1,6 +1,6 @@
1
1
  import { t as HttpError } from "./errors-CusN4x2l.mjs";
2
2
  import { c as loginHanded, d as vercelProjectFile, i as environmentProfile, t as asYou, u as vercelLogin } from "./node-Zd_C-gxP.mjs";
3
- import { t as createVercel } from "./backend-vercel-D87pIp2k.mjs";
3
+ import { t as createVercel } from "./backend-vercel-BNauAs7w.mjs";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { decodeJwt } from "jose";
6
6
  import { getVercelOidcToken } from "@vercel/oidc";
@@ -1,4 +1,4 @@
1
- import { n as VercelOptions, t as VercelBackend } from "./backend-vercel-CIRObA0t.mjs";
1
+ import { n as VercelOptions, t as VercelBackend } from "./backend-vercel-4tvcPKqX.mjs";
2
2
  //#region src/backend-vercel/index.default.d.ts
3
3
  /**
4
4
  * Your team's board, in Vercel's API. Outside Node there's no environment to find credentials or
@@ -1,4 +1,4 @@
1
- import { t as createVercel } from "./backend-vercel-D87pIp2k.mjs";
1
+ import { t as createVercel } from "./backend-vercel-BNauAs7w.mjs";
2
2
  //#region src/backend-vercel/index.default.ts
3
3
  /**
4
4
  * Your team's board, in Vercel's API. Outside Node there's no environment to find credentials or
@@ -1,4 +1,4 @@
1
- import { n as VercelOptions, t as VercelBackend } from "./backend-vercel-CIRObA0t.mjs";
1
+ import { n as VercelOptions, t as VercelBackend } from "./backend-vercel-4tvcPKqX.mjs";
2
2
  //#region src/backend-vercel/index.d.ts
3
3
  /** Where `token` comes from, in words: what `vb whoami` says. */
4
4
  export declare function credentialSource(): string;
@@ -1,2 +1,2 @@
1
- import { n as handTeam, r as vercel, t as credentialSource } from "./backend-vercel-DTrDiSk5.mjs";
1
+ import { n as handTeam, r as vercel, t as credentialSource } from "./backend-vercel-DQc_TaHp.mjs";
2
2
  export { credentialSource, handTeam, vercel };
@@ -0,0 +1,182 @@
1
+ import { n as DIRECT_UPLOAD_BYTES_MAX, t as ATTACHMENT_BYTES_MAX } from "./files-SniSmeEr.mjs";
2
+ import { i as Attachment } from "./schemas-DBmUO6pr.mjs";
3
+ import { r as badRequest, s as notFound, t as HttpError } from "./errors-CusN4x2l.mjs";
4
+ import { randomBytes } from "node:crypto";
5
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
+ import { dirname, join } from "node:path";
7
+ //#region src/server/blob.ts
8
+ /** Files in memory, for tests: forgets everything when the process ends. */
9
+ function memoryBlob() {
10
+ const files = /* @__PURE__ */ new Map();
11
+ return {
12
+ put: async (key, data, contentType) => void files.set(key, {
13
+ data: data.slice(),
14
+ contentType
15
+ }),
16
+ get: async (key) => files.get(key) ?? null
17
+ };
18
+ }
19
+ /** Files on disk, for development and `vb serve`: under `dir`, `.a2/attachments` by default. */
20
+ function fsBlob(dir = ".a2/attachments") {
21
+ const path = (key) => join(dir, ...key.split("/"));
22
+ return {
23
+ async put(key, data, contentType) {
24
+ await mkdir(dirname(path(key)), { recursive: true });
25
+ await writeFile(path(key), data);
26
+ await writeFile(`${path(key)}.type`, contentType);
27
+ },
28
+ async get(key) {
29
+ try {
30
+ const [data, contentType] = await Promise.all([readFile(path(key)), readFile(`${path(key)}.type`, "utf8")]);
31
+ return {
32
+ data: new Uint8Array(data),
33
+ contentType
34
+ };
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+ };
40
+ }
41
+ const dataKey = (id) => `board/attachments/${id}`;
42
+ /** A direct upload the board signed, until it's finished: what it's called, and who may finish it. */
43
+ const ticketKey = (id) => `board/uploads/${id}.json`;
44
+ const metaKey = (id) => `board/attachments/${id}.json`;
45
+ /** `shot.png`, as the board stores a name: no paths, no control characters. */
46
+ function fileName(name) {
47
+ return (name ?? "").split(/[\\/]/).pop().replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 255) || "file";
48
+ }
49
+ /** `image/png`, or `application/octet-stream` when the type isn't one. */
50
+ function mediaType(type) {
51
+ const value = (type ?? "").split(";")[0].trim().toLowerCase();
52
+ return /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(value) && value.length <= 255 ? value : "application/octet-stream";
53
+ }
54
+ /** A board without a blob store keeps no files: every file route says so. */
55
+ const noBlob = () => new HttpError(501, "no_files", "this board keeps no files: pass `blob` to `a2()`");
56
+ /** 128 random bits, base64url, drawn again when they'd start with `-`, which a command line reads as a flag. */
57
+ function attachmentId() {
58
+ for (;;) {
59
+ const id = randomBytes(16).toString("base64url");
60
+ if (!id.startsWith("-")) return id;
61
+ }
62
+ }
63
+ /**
64
+ * Stores an upload under a fresh ID, with its metadata beside it. `uploadedBy`
65
+ * is the principal that sent it, who can read it before any message carries it.
66
+ * `max` is the most it can be: `ATTACHMENT_BYTES_MAX` unless the board's own sync imports it.
67
+ */
68
+ async function writeUpload(blob, input, max = ATTACHMENT_BYTES_MAX) {
69
+ if (!blob) throw noBlob();
70
+ if (input.data.byteLength === 0) throw badRequest("the file is empty");
71
+ if (input.data.byteLength > max) throw new HttpError(413, "too_large", `attachments are at most ${max / 1024 / 1024} MB`);
72
+ const attachment = {
73
+ id: attachmentId(),
74
+ name: fileName(input.name),
75
+ type: mediaType(input.type),
76
+ size: input.data.byteLength
77
+ };
78
+ await blob.put(dataKey(attachment.id), input.data, attachment.type);
79
+ const meta = input.uploadedBy === void 0 ? attachment : {
80
+ ...attachment,
81
+ uploadedBy: input.uploadedBy
82
+ };
83
+ await blob.put(metaKey(attachment.id), new TextEncoder().encode(JSON.stringify(meta)), "application/json");
84
+ return attachment;
85
+ }
86
+ async function bytes(data) {
87
+ return data instanceof Uint8Array ? data : new Uint8Array(await new Response(data).arrayBuffer());
88
+ }
89
+ const attachmentNotFound = (id) => notFound(`no attachment: ${id}`, "attachment_not_found");
90
+ /**
91
+ * An upload's metadata, as the board recorded it, never what a request says,
92
+ * and who uploaded it: null for files the board imported itself, or stored
93
+ * before it recorded that.
94
+ */
95
+ async function readUpload(blob, id) {
96
+ if (!blob) throw noBlob();
97
+ const found = Attachment.shape.id.safeParse(id).success ? await blob.get(metaKey(id)) : null;
98
+ if (!found) throw attachmentNotFound(id);
99
+ const meta = JSON.parse(new TextDecoder().decode(await bytes(found.data)));
100
+ return {
101
+ attachment: Attachment.parse(meta),
102
+ uploadedBy: typeof meta.uploadedBy === "string" ? meta.uploadedBy : null
103
+ };
104
+ }
105
+ /** An attachment's bytes as the store keeps them, a stream or the bytes, for board() to serve. */
106
+ async function attachmentBody(blob, id) {
107
+ if (!blob) throw noBlob();
108
+ const found = await blob.get(dataKey(id));
109
+ if (!found) throw attachmentNotFound(id);
110
+ return found.data;
111
+ }
112
+ /** How long a direct upload's URL takes bytes: long enough for 100 MB on a slow link. */
113
+ const DIRECT_UPLOAD_TTL_MS = 18e5;
114
+ const noDirectUploads = () => new HttpError(501, "no_direct_uploads", `this board's blob store can't take direct uploads, so files are at most ${ATTACHMENT_BYTES_MAX / 1024 / 1024} MB`);
115
+ /**
116
+ * Starts a direct upload: a fresh ID, and a URL in the store to `PUT` its bytes to. The board
117
+ * keeps what the request said the file is until `finishUpload` checks the store has it.
118
+ */
119
+ async function startUpload(blob, input) {
120
+ if (!blob) throw noBlob();
121
+ if (!blob.uploadUrl || !blob.head) throw noDirectUploads();
122
+ if (input.size > 104857600) throw new HttpError(413, "too_large", `direct uploads are at most ${DIRECT_UPLOAD_BYTES_MAX / 1024 / 1024} MB`);
123
+ const expires = new Date(Date.now() + DIRECT_UPLOAD_TTL_MS);
124
+ const ticket = {
125
+ id: attachmentId(),
126
+ name: fileName(input.name),
127
+ type: mediaType(input.type),
128
+ size: input.size,
129
+ uploadedBy: input.uploadedBy,
130
+ expiresAt: expires.toISOString()
131
+ };
132
+ await blob.put(ticketKey(ticket.id), new TextEncoder().encode(JSON.stringify(ticket)), "application/json");
133
+ const { url, headers } = await blob.uploadUrl(dataKey(ticket.id), {
134
+ contentType: ticket.type,
135
+ maxBytes: ticket.size,
136
+ expiresAt: expires
137
+ });
138
+ return {
139
+ id: ticket.id,
140
+ url,
141
+ headers: {
142
+ ...headers,
143
+ "content-type": ticket.type
144
+ },
145
+ expiresAt: ticket.expiresAt
146
+ };
147
+ }
148
+ /**
149
+ * Finishes a direct upload: once the store has exactly the bytes its start said, the board records
150
+ * the attachment, as `writeUpload` does. Only its uploader may finish it; a second finish answers
151
+ * the same attachment. It never reads the record before writing it: a store with a cache (Vercel
152
+ * Blob's CDN) would keep that miss, and the post that names the file would find nothing.
153
+ */
154
+ async function finishUpload(blob, id, principal) {
155
+ if (!blob) throw noBlob();
156
+ const found = Attachment.shape.id.safeParse(id).success ? await blob.get(ticketKey(id)) : null;
157
+ const ticket = found ? JSON.parse(new TextDecoder().decode(await bytes(found.data))) : null;
158
+ if (!ticket || ticket.uploadedBy !== principal) throw notFound(`no upload: ${id}`, "upload_not_found");
159
+ if (!blob.head) throw noDirectUploads();
160
+ const stored = await blob.head(dataKey(id));
161
+ if (!stored) throw new HttpError(409, "not_uploaded", `nothing's been uploaded for ${id} yet: PUT the file to its URL first`);
162
+ if (stored.size !== ticket.size) throw badRequest(`the store has ${stored.size} bytes for ${id}, but its upload said ${ticket.size}`, "size_mismatch");
163
+ const attachment = {
164
+ id,
165
+ name: ticket.name,
166
+ type: ticket.type,
167
+ size: stored.size
168
+ };
169
+ try {
170
+ await blob.put(metaKey(id), new TextEncoder().encode(JSON.stringify({
171
+ ...attachment,
172
+ uploadedBy: principal
173
+ })), "application/json");
174
+ } catch (error) {
175
+ const done = await blob.get(metaKey(id)).catch(() => null);
176
+ if (!done) throw error;
177
+ return Attachment.parse(JSON.parse(new TextDecoder().decode(await bytes(done.data))));
178
+ }
179
+ return attachment;
180
+ }
181
+ //#endregion
182
+ export { fsBlob as a, readUpload as c, finishUpload as i, startUpload as l, attachmentNotFound as n, mediaType as o, fileName as r, memoryBlob as s, attachmentBody as t, writeUpload as u };
@@ -1,5 +1,5 @@
1
- import "./folds-jS9wlh9_.mjs";
2
- import "./errors-CB4tnZGz.mjs";
1
+ import "./folds-ZkHM4M-v.mjs";
2
+ import "./errors-DDktVeUa.mjs";
3
3
  //#region src/server/blob.d.ts
4
4
  /**
5
5
  * Where attachments' bytes live. The board writes each file once, under a key
@@ -1,6 +1,6 @@
1
- import { d as ExternalFile, f as ExternalLink, l as ExternalAuthor, t as Actor } from "./folds-jS9wlh9_.mjs";
2
- import { dt as SourceJoin, mt as SourceRead } from "./client-xLxeDz0f.mjs";
3
- import { M as SyncRoot, O as ProviderEvent, a as ProviderOptions, c as ReceivedEvent, d as ResolvedTarget, i as ProviderFile, n as FileDelivery, o as ProviderPost, p as SyncProvider, r as ProviderDelivery, s as Receive, t as FetchedLink, y as SyncFn } from "./provider-Cdg1qadh.mjs";
1
+ import { d as ExternalFile, f as ExternalLink, l as ExternalAuthor, t as Actor } from "./folds-ZkHM4M-v.mjs";
2
+ import { dt as SourceJoin, mt as SourceRead } from "./client-BqAjk0Q4.mjs";
3
+ import { M as SyncRoot, O as ProviderEvent, a as ProviderOptions, c as ReceivedEvent, d as ResolvedTarget, i as ProviderFile, n as FileDelivery, o as ProviderPost, p as SyncProvider, r as ProviderDelivery, s as Receive, t as FetchedLink, y as SyncFn } from "./provider-DmSTp9lI.mjs";
4
4
  import { Adapter, Author, FileUpload, Message, ReactionEvent } from "chat";
5
5
  //#region src/sync/types.d.ts
6
6
  declare function externalAuthor(platform: string, author: Author): ExternalAuthor;
@@ -45,6 +45,7 @@ type ChatTransport = Omit<ProviderOptions, 'uploadsFiles'> & {
45
45
  /** A channel or thread, read on demand without syncing it. */
46
46
  read?(target: string, options: {
47
47
  limit: number;
48
+ after: string | null;
48
49
  }): Promise<SourceRead>;
49
50
  /** Adds the app to a channel so `read` can read it. */
50
51
  join?(target: string): Promise<SourceJoin>;
@@ -126,6 +127,7 @@ declare abstract class ChatSyncProvider<E extends ProviderEvent = ProviderEvent>
126
127
  url(thread: string, message: string): Promise<string | null>;
127
128
  read(target: string, options: {
128
129
  limit: number;
130
+ after: string | null;
129
131
  }): Promise<SourceRead>;
130
132
  join(target: string): Promise<SourceJoin>;
131
133
  fetchFile(file: ExternalFile): Promise<Uint8Array | null>;
@@ -1,5 +1,5 @@
1
- import "./external-BV_cUncs.mjs";
2
- import { n as ResolvedTarget, r as SyncProvider } from "./provider-poIkXapd.mjs";
1
+ import "./external-D3p5T57R.mjs";
2
+ import { n as ResolvedTarget, r as SyncProvider } from "./provider-BaVmi4Hi.mjs";
3
3
  import { n as userTokensOf } from "./users-yryHKFMo.mjs";
4
4
  import { AsyncLocalStorage } from "node:async_hooks";
5
5
  import { Chat, stringifyMarkdown } from "chat";
@@ -1,2 +1,2 @@
1
- import { a as option, i as groups, n as filters, o as options, r as globalOptions, s as usage, t as commands } from "./commands-BFookFtn.mjs";
1
+ import { a as option, i as groups, n as filters, o as options, r as globalOptions, s as usage, t as commands } from "./commands-C3R1AWgm.mjs";
2
2
  export { commands, filters, globalOptions, groups, option, options, usage };
@@ -1,5 +1,5 @@
1
- import { d as Board } from "./backend-ZKYTe3xa.mjs";
2
- import "./index-C05it0mC.mjs";
1
+ import { d as Board } from "./backend-CPC_vyOo.mjs";
2
+ import "./index-D3VvNfh_.mjs";
3
3
  //#region src/cli/failure.d.ts
4
4
  /**
5
5
  * What kind of failure it is, which fixes the exit code: 2 for `usage` and `missing_input`, 130
@@ -1,4 +1,4 @@
1
- import { a as commandLine, c as failureLines, i as mainWith, l as formatFailure, n as commandWord, o as exitCode, r as main, s as failureDocument, t as Failed } from "./main-eci7w-aP.mjs";
1
+ import { a as commandLine, c as failureLines, i as mainWith, l as formatFailure, n as commandWord, o as exitCode, r as main, s as failureDocument, t as Failed } from "./main-CVBqe3VO.mjs";
2
2
  //#region src/cli/embedded.ts
3
3
  /**
4
4
  * Board's CLI as `appName` names it in help, usage, and errors (`vercel board`). With no board
package/dist/cli.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { t as ATTACHMENT_BYTES_MAX } from "./files-SniSmeEr.mjs";
3
- import "./schemas-Be08nyfW.mjs";
4
- import { a as option } from "./commands-BFookFtn.mjs";
3
+ import "./schemas-DBmUO6pr.mjs";
4
+ import { a as option } from "./commands-C3R1AWgm.mjs";
5
5
  import { l as projectDir } from "./node-Zd_C-gxP.mjs";
6
- import { r as main, u as who } from "./main-eci7w-aP.mjs";
6
+ import { r as main, u as who } from "./main-CVBqe3VO.mjs";
7
7
  import { randomBytes, timingSafeEqual } from "node:crypto";
8
8
  import { appendFile, readFile } from "node:fs/promises";
9
9
  import { join } from "node:path";
@@ -661,7 +661,7 @@ const local = {
661
661
  if (args.length > 0) throw usage("serve takes no arguments: pass its port with --port, and its directory with --dir.");
662
662
  const port = portFlag();
663
663
  const root = projectDir();
664
- const { localHandle, serveLocal } = await import("./serve-B09an9DQ.mjs");
664
+ const { localHandle, serveLocal } = await import("./serve-G9RuaDSr.mjs");
665
665
  const handle = flags.handle ?? process.env.BOARD_HANDLE;
666
666
  const local = await serveLocal({
667
667
  dir: flags.dir ?? join(root, ".board"),
@@ -1,5 +1,5 @@
1
1
  import { n as AccessSummary } from "./access-CV96SYgr.mjs";
2
- import { a as ExternalCopy, g as BoardEvent, o as Message, p as Link, r as Channel, t as Actor, u as ExternalEvent, x as Attachment } from "./folds-jS9wlh9_.mjs";
2
+ import { a as ExternalCopy, g as BoardEvent, o as Message, p as Link, r as Channel, t as Actor, u as ExternalEvent, x as Attachment } from "./folds-ZkHM4M-v.mjs";
3
3
  import { z } from "zod";
4
4
  //#region src/api/routes.d.ts
5
5
  /**
@@ -171,6 +171,7 @@ declare const AckBody: z.ZodObject<{
171
171
  declare const SourceReadQuery: z.ZodObject<{
172
172
  target: z.ZodString;
173
173
  limit: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
174
+ after: z.ZodOptional<z.ZodString>;
174
175
  }, z.core.$strip>;
175
176
  /** A channel to add the board's app to, so it can be read on demand: `#name`, an ID, or a link into it. */
176
177
  declare const SourceJoinBody: z.ZodObject<{
@@ -495,6 +496,7 @@ declare const InboxPushBody: z.ZodObject<{
495
496
  type: z.ZodString;
496
497
  title: z.ZodString;
497
498
  url: z.ZodNullable<z.ZodString>;
499
+ body: z.ZodOptional<z.ZodNullable<z.ZodString>>;
498
500
  labels: z.ZodRecord<z.ZodString, z.ZodString>;
499
501
  unset: z.ZodArray<z.ZodString>;
500
502
  owns: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -911,6 +913,8 @@ type SourceRead = {
911
913
  messages: SourceMessage[];
912
914
  /** The platform has more than `limit`: older messages in a channel, later replies in a thread. */
913
915
  more: boolean;
916
+ /** Where to pick up, as `after`, when the platform can page past `limit`. */
917
+ next?: string | null;
914
918
  };
915
919
  /** A channel the board's app joined, or was already in (`joined: false`). */
916
920
  type SourceJoin = {
@@ -1226,6 +1230,7 @@ declare const routes: {
1226
1230
  type: z.ZodString;
1227
1231
  title: z.ZodString;
1228
1232
  url: z.ZodNullable<z.ZodString>;
1233
+ body: z.ZodOptional<z.ZodNullable<z.ZodString>>;
1229
1234
  labels: z.ZodRecord<z.ZodString, z.ZodString>;
1230
1235
  unset: z.ZodArray<z.ZodString>;
1231
1236
  owns: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -1535,6 +1540,7 @@ declare const routes: {
1535
1540
  readonly query: z.ZodObject<{
1536
1541
  target: z.ZodString;
1537
1542
  limit: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
1543
+ after: z.ZodOptional<z.ZodString>;
1538
1544
  }, z.core.$strip>;
1539
1545
  };
1540
1546
  /** Adds the app to a public channel so it can be read, where that sync runs and opted in with `join`. Full access only. */
@@ -1930,6 +1936,7 @@ declare function createBoardClient(options: ClientOptions): {
1930
1936
  */
1931
1937
  readSource: (key: string, target: string, opts?: {
1932
1938
  limit?: number;
1939
+ after?: string;
1933
1940
  }) => Promise<SourceRead>;
1934
1941
  /** Adds the app to a public channel so `readSource` can read it, where the sync opted in with `join`. Full access only. */
1935
1942
  joinSource: (key: string, target: string) => Promise<SourceJoin>;
@@ -590,10 +590,13 @@ const commands = [
590
590
  required: true,
591
591
  description: "a channel ('#general'), a thread, or a message link"
592
592
  }],
593
- options: [option("limit", "messages to read")],
593
+ options: [option("limit", "messages to read"), option("after", "where the last read left off: the cursor it printed")],
594
594
  examples: [{
595
595
  name: "read a Slack channel",
596
596
  value: "sources slack read '#general'"
597
+ }, {
598
+ name: "read a Linear project's issues",
599
+ value: "sources linear read https://linear.app/acme/project/checkout-a1b2c3"
597
600
  }]
598
601
  }, {
599
602
  name: "join",
@@ -1,4 +1,4 @@
1
- import { d as Board } from "./backend-ZKYTe3xa.mjs";
1
+ import { d as Board } from "./backend-CPC_vyOo.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,14 +1,14 @@
1
1
  import { a as typeOf, i as fileSize, r as IMPORT_BYTES_MAX, t as ATTACHMENT_BYTES_MAX } from "./files-SniSmeEr.mjs";
2
- import { C as toStorable, c as ChannelPath, m as MESSAGE_TEXT_MAX } from "./schemas-Be08nyfW.mjs";
3
- import { c as externalMessageKey, l as isThreadTarget, s as channelOf } from "./external-BV_cUncs.mjs";
2
+ import { C as toStorable, c as ChannelPath, m as MESSAGE_TEXT_MAX } from "./schemas-DBmUO6pr.mjs";
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
5
  import { r as derivedId, t as waitUntil } from "./wait-until-Dw4XrIDQ.mjs";
6
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-ChKM1zuu.mjs";
8
+ import { t as DroppedError } from "./middleware-CXjEeef4.mjs";
9
9
  import { t as afterRetry } from "./attempts-HXcskN1w.mjs";
10
- import { a as channelMissing, i as UnreadableError, n as ResolvedTarget, o as routesFor, t as ReceivedEvent } from "./provider-poIkXapd.mjs";
11
- import { a as immutable, n as SyncOverrides } from "./events-CewD4kw7.mjs";
10
+ import { a as channelMissing, i as UnreadableError, n as ResolvedTarget, o as routesFor, t as ReceivedEvent } from "./provider-BaVmi4Hi.mjs";
11
+ import { a as immutable, n as SyncOverrides } from "./events-qf5XG5VQ.mjs";
12
12
  import { t as displayText } from "./bots-CGkO76qH.mjs";
13
13
  //#region src/sync/engine.ts
14
14
  /** `files`: a message with files may have no text. */
@@ -872,7 +872,7 @@ function createSyncEngine(entries) {
872
872
  const root = await messageOf(rootId);
873
873
  const createdHere = root?.origin?.message === `lifecycle:${meta.id}`;
874
874
  const desired = labels((createdHere ? rootDraft({
875
- text: event.url ? `${event.title}\n${event.url}` : event.title,
875
+ text: lifecycleText(event),
876
876
  labels: event.labels,
877
877
  author: event.author,
878
878
  raw
@@ -883,7 +883,7 @@ function createSyncEngine(entries) {
883
883
  if (root && !root.deleted && (Object.keys(set).length || unset.length)) await setLabels(event.author, set, unset);
884
884
  if (event.retitled && root && !root.deleted && root.from === key && root.origin?.thread === event.thread) {
885
885
  const text = clampText(overrides.text ?? rootDraft({
886
- text: event.url ? `${event.title}\n${event.url}` : event.title,
886
+ text: lifecycleText(event),
887
887
  labels: root.labels,
888
888
  author: event.author,
889
889
  raw
@@ -916,7 +916,7 @@ function createSyncEngine(entries) {
916
916
  message: null
917
917
  };
918
918
  const draft = rootDraft({
919
- text: event.url ? `${event.title}\n${event.url}` : event.title,
919
+ text: lifecycleText(event),
920
920
  labels: event.labels,
921
921
  author: event.author,
922
922
  raw
@@ -1289,14 +1289,14 @@ function createSyncEngine(entries) {
1289
1289
  postedAsPerson = true;
1290
1290
  return asPerson;
1291
1291
  }
1292
- return platform.post(native(target), "channel", body);
1292
+ if (root.attachments.length === 0 || !platform.startsThreadsWithFiles) return platform.post(native(target), "channel", body);
1293
1293
  }
1294
1294
  try {
1295
1295
  const sent = await platform.post(native(target), "channel", {
1296
1296
  ...body,
1297
1297
  files: await messageFiles(root)
1298
1298
  });
1299
- uploaded = sent.files ?? [];
1299
+ uploaded = mediaOnly ? sent.files ?? [] : sent.files;
1300
1300
  return sent;
1301
1301
  } catch (error) {
1302
1302
  if (!platform.refusesFiles(error) && !keepsNoFiles(error)) throw error;
@@ -1500,10 +1500,13 @@ function createSyncEngine(entries) {
1500
1500
  return 403;
1501
1501
  };
1502
1502
  /** A platform channel or thread, read for the caller: nothing is synced, stored, or joined. */
1503
- async function readSource(key, target, limit) {
1503
+ async function readSource(key, target, limit, after) {
1504
1504
  const platform = platformOf(key);
1505
1505
  try {
1506
- return await platform.read(target, { limit });
1506
+ return await platform.read(target, {
1507
+ limit,
1508
+ after
1509
+ });
1507
1510
  } catch (error) {
1508
1511
  if (error instanceof UnreadableError) throw new HttpError(unreadableStatus(error.code), error.code, error.message);
1509
1512
  throw badRequest(`can't read ${target} from ${key}: ${describe(error)}`, "platform_error");
@@ -1542,5 +1545,10 @@ function createSyncEngine(entries) {
1542
1545
  joinSource
1543
1546
  };
1544
1547
  }
1548
+ /** A lifecycle root's text: the title, its link, and what the PR or issue says. */
1549
+ function lifecycleText(event) {
1550
+ const head = event.url ? `${event.title}\n${event.url}` : event.title;
1551
+ return event.body ? `${head}\n\n${event.body}` : head;
1552
+ }
1545
1553
  //#endregion
1546
1554
  export { createSyncEngine };
@@ -1,4 +1,4 @@
1
- import { r as ErrorOrigin } from "./client-xLxeDz0f.mjs";
1
+ import { r as ErrorOrigin } from "./client-BqAjk0Q4.mjs";
2
2
  //#region src/server/errors.d.ts
3
3
  declare class HttpError extends Error {
4
4
  readonly status: number;
@@ -1,5 +1,5 @@
1
- import { d as LabelKey, p as Labels } from "./schemas-Be08nyfW.mjs";
2
- import { n as ExternalEvent, r as ExternalLocation } from "./external-BV_cUncs.mjs";
1
+ import { d as LabelKey, p as Labels } from "./schemas-DBmUO6pr.mjs";
2
+ import { n as ExternalEvent, r as ExternalLocation } from "./external-D3p5T57R.mjs";
3
3
  import { z } from "zod";
4
4
  //#region src/sync/events.ts
5
5
  const EventBase = z.object({