@vercel/board 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +8 -17
  2. package/dist/{a2-D_28Pdip.mjs → a2-DngWY2Eb.mjs} +573 -361
  3. package/dist/{access-CQ39gqEV.mjs → access-CY5xVMIK.mjs} +19 -3
  4. package/dist/{access-CV96SYgr.d.mts → access-DPcrKIH_.d.mts} +6 -1
  5. package/dist/access.d.mts +4 -4
  6. package/dist/access.mjs +5 -4
  7. package/dist/{actors-xPiamLWk.mjs → actors-Bm-ojWWT.mjs} +2 -2
  8. package/dist/{auth-BPinwfN0.mjs → auth-CyO2aw5n.mjs} +127 -14
  9. package/dist/{auth-CU56Q2Xh.d.mts → auth-DLYSt_-A.d.mts} +32 -4
  10. package/dist/{backend-n3hajfrD.d.mts → backend-Bob-5CM7.d.mts} +86 -41
  11. package/dist/backend-a2/blob-vercel.d.mts +1 -1
  12. package/dist/backend-a2/contract.d.mts +37 -97
  13. package/dist/backend-a2/contract.mjs +1 -1
  14. package/dist/backend-a2/store-memory.d.mts +1 -1
  15. package/dist/backend-a2/store-memory.mjs +2 -2
  16. package/dist/backend-a2/store-postgres.d.mts +1 -1
  17. package/dist/backend-a2/store-postgres.mjs +1 -1
  18. package/dist/backend-a2/store-redis-http.d.mts +1 -1
  19. package/dist/backend-a2/store-redis-http.mjs +1 -1
  20. package/dist/backend-a2/store-redis.d.mts +1 -1
  21. package/dist/backend-a2/store-redis.mjs +1 -1
  22. package/dist/backend-a2/store-sqlite.d.mts +1 -1
  23. package/dist/backend-a2/store-sqlite.mjs +2 -2
  24. package/dist/backend-a2.d.mts +131 -240
  25. package/dist/backend-a2.mjs +2 -2
  26. package/dist/{backend-http-3PoBYQoo.d.mts → backend-http-BRrRbjRx.d.mts} +49 -45
  27. package/dist/{backend-http-BPaNYLWj.mjs → backend-http-CvUKOn-m.mjs} +11 -9
  28. package/dist/{backend-http-16SbvnAK.mjs → backend-http-D8mUzuKQ.mjs} +2 -2
  29. package/dist/backend-http.d.mts +1 -1
  30. package/dist/backend-http.mjs +1 -1
  31. package/dist/backend-http.node.d.mts +1 -1
  32. package/dist/backend-http.node.mjs +1 -1
  33. package/dist/{backend-vercel-Kd8nvW5A.mjs → backend-vercel-Ba8JDtqW.mjs} +3 -3
  34. package/dist/{backend-vercel-qznuDxnN.d.mts → backend-vercel-BaSEUdwg.d.mts} +49 -45
  35. package/dist/{backend-vercel-C4Kee2-K.mjs → backend-vercel-CmY9yZ9m.mjs} +80 -15
  36. package/dist/backend-vercel.d.mts +1 -1
  37. package/dist/backend-vercel.mjs +1 -1
  38. package/dist/backend-vercel.node.d.mts +1 -1
  39. package/dist/backend-vercel.node.mjs +1 -1
  40. package/dist/{blob-CqILvdmr.d.mts → blob-Bx8QPWEd.d.mts} +2 -2
  41. package/dist/{blob-DQK5prXT.mjs → blob-WbB2DJKn.mjs} +30 -11
  42. package/dist/{chat-provider-D6jVT4wS.d.mts → chat-provider-BChYrrA7.d.mts} +32 -28
  43. package/dist/{chat-provider-COtsU5jB.mjs → chat-provider-DkFp2hrh.mjs} +105 -60
  44. package/dist/cli-commands.d.mts +1 -1
  45. package/dist/cli-commands.mjs +1 -1
  46. package/dist/cli-embedded.d.mts +2 -2
  47. package/dist/cli-embedded.mjs +1 -1
  48. package/dist/cli.mjs +4 -4
  49. package/dist/{client-CjAatpV4.d.mts → client-COfYrgfV.d.mts} +184 -221
  50. package/dist/{commands-BtH69yqt.d.mts → commands-DK4W3HCJ.d.mts} +30 -10
  51. package/dist/{commands-B5DHjZKK.mjs → commands-L1JSGz2Z.mjs} +162 -45
  52. package/dist/{conformance-C7fwIvmN.mjs → conformance-DK43HiHf.mjs} +224 -20
  53. package/dist/conformance.d.mts +8 -2
  54. package/dist/conformance.mjs +1 -1
  55. package/dist/{engine-DkdkXQEy.mjs → engine-DurYIRt8.mjs} +29 -210
  56. package/dist/{errors-87TVtXeb.d.mts → errors-2vGs49Db.d.mts} +1 -1
  57. package/dist/{errors-C_5rB8Iz.mjs → errors-CJXhhzvT.mjs} +28 -23
  58. package/dist/{provider-BaVmi4Hi.mjs → events-lujPA3gQ.mjs} +80 -17
  59. package/dist/{exports-CG0P8Qi4.mjs → exports-CWIdN0Ub.mjs} +146 -80
  60. package/dist/exports-FBOQWT7O.d.mts +5 -0
  61. package/dist/{folds-DGYkMW19.mjs → folds-Cu9Q9zyQ.mjs} +2 -22
  62. package/dist/{folds-8wRRXjAt.d.mts → folds-DLUzfibJ.d.mts} +13 -40
  63. package/dist/{index-BygAXP7t.d.mts → index-Ho-ayyQL.d.mts} +2 -2
  64. package/dist/index.d.mts +7 -7
  65. package/dist/{index.default-DhkgpfR2.mjs → index.default-BUcwDaAe.mjs} +1 -1
  66. package/dist/index.mjs +5 -5
  67. package/dist/index.node.d.mts +7 -7
  68. package/dist/index.node.mjs +3 -3
  69. package/dist/inspect/app.js +62 -61
  70. package/dist/inspect/engine.js +3 -3
  71. package/dist/{main-D2s-QBOK.mjs → main-Dz-8komG.mjs} +189 -112
  72. package/dist/{middleware-DlJQYiwV.mjs → middleware-BavYSDUa.mjs} +1 -1
  73. package/dist/{node-D8Rtg1su.mjs → node-BgeGcwX0.mjs} +7 -3
  74. package/dist/{project-C_2xs57L.mjs → project-CQ4VUibA.mjs} +1 -1
  75. package/dist/{provider-Bm46LAdp.d.mts → provider-gynDUoyW.d.mts} +13 -43
  76. package/dist/{redis-tables-BWJZHUGg.mjs → redis-tables-DHf85ixI.mjs} +1 -19
  77. package/dist/{reducers-8jqLewgw.mjs → reducers-DcENwtwZ.mjs} +72 -75
  78. package/dist/{serve-lPoBDEds.mjs → serve-CTmow-YH.mjs} +6 -6
  79. package/dist/{server-D82a4uMe.mjs → server-DG8D6VV0.mjs} +4 -4
  80. package/dist/{serving-DKny38LK.mjs → serving-DBFRo6U_.mjs} +1 -1
  81. package/dist/{sql-Bj0fk6dn.mjs → sql-CT5xVuGr.mjs} +2 -8
  82. package/dist/{sqlite-db-Dc8G78XV.mjs → sqlite-db-0WeSOEZN.mjs} +1 -1
  83. package/dist/{sync-kapso.d.mts → sync-chat-sdk.d.mts} +33 -37
  84. package/dist/sync-chat-sdk.mjs +44 -0
  85. package/dist/sync-github.d.mts +5 -18
  86. package/dist/sync-github.mjs +4 -70
  87. package/dist/sync-linear.d.mts +3 -3
  88. package/dist/sync-linear.mjs +2 -3
  89. package/dist/sync-slack.d.mts +7 -7
  90. package/dist/sync-slack.mjs +33 -54
  91. package/dist/sync.d.mts +3 -3
  92. package/dist/sync.mjs +2 -4
  93. package/dist/{tokens-C5_yGRlL.mjs → tokens-Dnyd_y1n.mjs} +15 -6
  94. package/dist/{types-shZmGZ-q.d.mts → types-CmrFjpml.d.mts} +1 -3
  95. package/dist/{watchers-hash-B_5b0-ZG.mjs → watchers-hash-BjwlnGD9.mjs} +85 -105
  96. package/docs/01-board/01-mounting.md +2 -2
  97. package/docs/01-board/03-identity.md +5 -6
  98. package/docs/01-board/04-messages.md +3 -2
  99. package/docs/01-board/05-reading.md +2 -1
  100. package/docs/01-board/06-watchers.md +8 -6
  101. package/docs/01-board/08-middleware.md +1 -1
  102. package/docs/01-board/09-access.md +145 -14
  103. package/docs/02-sync/01-overview.md +19 -26
  104. package/docs/02-sync/02-functions.md +8 -9
  105. package/docs/02-sync/03-slack.md +5 -5
  106. package/docs/02-sync/04-github.md +2 -2
  107. package/docs/02-sync/06-providers.md +91 -62
  108. package/docs/03-guides/01-coding-agents.md +1 -1
  109. package/docs/03-guides/03-ci.md +7 -4
  110. package/docs/04-reference/01-cli.md +86 -41
  111. package/docs/04-reference/02-board.md +56 -23
  112. package/docs/04-reference/03-http.md +59 -32
  113. package/docs/04-reference/04-log.md +8 -11
  114. package/docs/04-reference/05-conformance.md +35 -6
  115. package/docs/index.md +2 -2
  116. package/package.json +6 -9
  117. package/sql/postgres/board.sql +1 -1
  118. package/sql/postgres/schema.sql +1 -1
  119. package/dist/events-qf5XG5VQ.mjs +0 -75
  120. package/dist/exports-CrGA5_yN.d.mts +0 -5
  121. package/dist/sync-kapso.mjs +0 -89
  122. package/dist/sync.chat.d.mts +0 -2
  123. package/dist/sync.chat.mjs +0 -2
  124. package/dist/users-yryHKFMo.mjs +0 -42
  125. package/docs/02-sync/05-whatsapp.md +0 -152
@@ -40,8 +40,10 @@ type CliCommand = {
40
40
  readonly subcommands?: readonly CliCommand[];
41
41
  /** Its arguments come before its subcommand, as in `sources <key> read <target>`. */
42
42
  readonly argumentsBeforeSubcommand?: true;
43
- /** It does something without a subcommand too, as `identity` lists your links. */
43
+ /** It does something without a subcommand too, as `sources` lists the sync entries. */
44
44
  readonly runsWithoutSubcommand?: true;
45
+ /** The subcommand its command runs when given none, as `channels` runs `channels list`. */
46
+ readonly default?: true;
45
47
  /** Out of help: it still runs, for what calls it. */
46
48
  readonly hidden?: true;
47
49
  };
@@ -61,7 +63,7 @@ declare const options: {
61
63
  readonly type: 'string';
62
64
  readonly value: '@handle';
63
65
  readonly optionalValue: true;
64
- readonly description: 'mentioning a handle, or you without one';
66
+ readonly description: 'mentioning a handle or email, or you without one';
65
67
  };
66
68
  readonly label: {
67
69
  readonly type: 'string';
@@ -202,14 +204,6 @@ declare const options: {
202
204
  readonly value: 'duration';
203
205
  readonly description: 'how long to wait (default: 30m)';
204
206
  };
205
- readonly code: {
206
- readonly type: 'boolean';
207
- readonly description: 'post a code from your account instead of signing in';
208
- };
209
- readonly 'no-wait': {
210
- readonly type: 'boolean';
211
- readonly description: "start linking, and don't wait for it to finish";
212
- };
213
207
  readonly 'no-open': {
214
208
  readonly type: 'boolean';
215
209
  readonly description: "don't open the browser";
@@ -245,6 +239,32 @@ declare const options: {
245
239
  readonly value: 'N';
246
240
  readonly description: 'repeating this many times';
247
241
  };
242
+ readonly can: {
243
+ readonly type: 'string';
244
+ readonly value: 'scope';
245
+ readonly description: 'what it may do (default: *=read,post,react,label)';
246
+ };
247
+ readonly claim: {
248
+ readonly type: 'string';
249
+ readonly multiple: true;
250
+ readonly value: 'k=v';
251
+ readonly description: 'a claim its tokens carry; the same name again is any of them';
252
+ };
253
+ readonly profile: {
254
+ readonly type: 'string';
255
+ readonly value: 'name';
256
+ readonly description: 'the profile every request with it writes as';
257
+ };
258
+ readonly session: {
259
+ readonly type: 'string';
260
+ readonly value: 'id';
261
+ readonly description: 'the session every request with it writes from';
262
+ };
263
+ readonly expires: {
264
+ readonly type: 'string';
265
+ readonly value: 'duration';
266
+ readonly description: 'how long it lives (default: 30d, at most 90d)';
267
+ };
248
268
  readonly port: {
249
269
  readonly type: 'string';
250
270
  readonly value: 'N';
@@ -15,7 +15,7 @@ const options = {
15
15
  type: "string",
16
16
  value: "@handle",
17
17
  optionalValue: true,
18
- description: "mentioning a handle, or you without one"
18
+ description: "mentioning a handle or email, or you without one"
19
19
  },
20
20
  label: {
21
21
  type: "string",
@@ -156,14 +156,6 @@ const options = {
156
156
  value: "duration",
157
157
  description: "how long to wait (default: 30m)"
158
158
  },
159
- code: {
160
- type: "boolean",
161
- description: "post a code from your account instead of signing in"
162
- },
163
- "no-wait": {
164
- type: "boolean",
165
- description: "start linking, and don't wait for it to finish"
166
- },
167
159
  "no-open": {
168
160
  type: "boolean",
169
161
  description: "don't open the browser"
@@ -199,6 +191,32 @@ const options = {
199
191
  value: "N",
200
192
  description: "repeating this many times"
201
193
  },
194
+ can: {
195
+ type: "string",
196
+ value: "scope",
197
+ description: "what it may do (default: *=read,post,react,label)"
198
+ },
199
+ claim: {
200
+ type: "string",
201
+ multiple: true,
202
+ value: "k=v",
203
+ description: "a claim its tokens carry; the same name again is any of them"
204
+ },
205
+ profile: {
206
+ type: "string",
207
+ value: "name",
208
+ description: "the profile every request with it writes as"
209
+ },
210
+ session: {
211
+ type: "string",
212
+ value: "id",
213
+ description: "the session every request with it writes from"
214
+ },
215
+ expires: {
216
+ type: "string",
217
+ value: "duration",
218
+ description: "how long it lives (default: 30d, at most 90d)"
219
+ },
202
220
  port: {
203
221
  type: "string",
204
222
  value: "N",
@@ -284,70 +302,169 @@ const commands = [
284
302
  }]
285
303
  },
286
304
  {
287
- group: "the board",
288
- name: "channels",
289
- description: "channels, grouped by path prefix",
305
+ group: "you",
306
+ name: "tokens",
307
+ description: "tokens that act as you, narrowed, for a widget, a sandbox, or a script",
290
308
  arguments: [],
291
309
  options: [],
310
+ runsWithoutSubcommand: true,
292
311
  examples: [{
293
- name: "list the channels",
294
- value: "channels"
295
- }]
312
+ name: "list your tokens",
313
+ value: "tokens"
314
+ }],
315
+ subcommands: [
316
+ {
317
+ name: "list",
318
+ default: true,
319
+ description: "your live tokens, or with manage, everyone's",
320
+ arguments: [],
321
+ options: [],
322
+ examples: [{
323
+ name: "list your tokens",
324
+ value: "tokens list"
325
+ }]
326
+ },
327
+ {
328
+ name: "create",
329
+ description: "mint one, and print its secret once",
330
+ arguments: [{
331
+ name: "name",
332
+ required: true,
333
+ description: "what it is for, e.g. feedback-widget"
334
+ }],
335
+ options: [
336
+ option("can"),
337
+ option("profile"),
338
+ option("session"),
339
+ option("expires")
340
+ ],
341
+ examples: [{
342
+ name: "a token that only posts feedback",
343
+ value: "tokens create feedback-widget --can feedback/=post"
344
+ }, {
345
+ name: "a week's token for a sandbox agent",
346
+ value: "tokens create sandbox-7 --can dev/=read,post,react --profile lemur --expires 7d"
347
+ }]
348
+ },
349
+ {
350
+ name: "revoke",
351
+ description: "stop one, and every token minted with it",
352
+ arguments: [{
353
+ name: "token",
354
+ required: true,
355
+ description: "its ID, as tokens shows it (t12)"
356
+ }],
357
+ options: [],
358
+ examples: [{
359
+ name: "revoke a token",
360
+ value: "tokens revoke t12"
361
+ }]
362
+ }
363
+ ]
296
364
  },
297
365
  {
298
366
  group: "the board",
299
- name: "actors",
300
- description: "actors and their handles",
367
+ name: "grants",
368
+ description: "who the board lets in beside its code's rules: tokens from an OIDC issuer with these claims",
301
369
  arguments: [],
302
370
  options: [],
371
+ runsWithoutSubcommand: true,
303
372
  examples: [{
304
- name: "list who has written here",
305
- value: "actors"
306
- }]
373
+ name: "list the grants",
374
+ value: "grants"
375
+ }],
376
+ subcommands: [
377
+ {
378
+ name: "list",
379
+ default: true,
380
+ description: "the board's grants",
381
+ arguments: [],
382
+ options: [],
383
+ examples: [{
384
+ name: "list the grants",
385
+ value: "grants list"
386
+ }]
387
+ },
388
+ {
389
+ name: "create",
390
+ description: "let them in, or change what they may do",
391
+ arguments: [{
392
+ name: "issuer",
393
+ required: true,
394
+ description: "vercel, github, or an https URL"
395
+ }],
396
+ options: [option("claim"), option("can")],
397
+ examples: [{
398
+ name: "let a workflow post in ci/",
399
+ value: "grants create github --claim repository=acme/web --can ci/=read,post"
400
+ }, {
401
+ name: "let a project's production post feedback",
402
+ value: "grants create vercel --claim project_id=prj_123 --claim environment=production --can feedback/=post"
403
+ }]
404
+ },
405
+ {
406
+ name: "revoke",
407
+ description: "stop letting them in",
408
+ arguments: [{
409
+ name: "grant",
410
+ required: true,
411
+ description: "its ID, as grants shows it (g3)"
412
+ }],
413
+ options: [],
414
+ examples: [{
415
+ name: "revoke a grant",
416
+ value: "grants revoke g3"
417
+ }]
418
+ }
419
+ ]
307
420
  },
308
421
  {
309
422
  group: "the board",
310
- name: "identity",
311
- description: "your linked Slack and GitHub accounts",
423
+ name: "channels",
424
+ description: "channels, grouped by path prefix: a first post makes one, and so does create",
312
425
  arguments: [],
313
426
  options: [],
314
427
  runsWithoutSubcommand: true,
315
428
  examples: [{
316
- name: "list your linked accounts",
317
- value: "identity"
429
+ name: "list the channels",
430
+ value: "channels"
318
431
  }],
319
432
  subcommands: [{
320
- name: "link",
321
- description: "link your account on a sync entry: sign in, or post a code from it",
322
- arguments: [{
323
- name: "key",
324
- required: true,
325
- description: "the sync entry, e.g. slack"
326
- }],
327
- options: [
328
- option("code"),
329
- option("no-wait"),
330
- option("no-open")
331
- ],
433
+ name: "list",
434
+ default: true,
435
+ description: "channels, grouped by path prefix",
436
+ arguments: [],
437
+ options: [],
332
438
  examples: [{
333
- name: "link your Slack account",
334
- value: "identity link slack"
439
+ name: "list the channels",
440
+ value: "channels list"
335
441
  }]
336
442
  }, {
337
- name: "unlink",
338
- description: "unlink one, by handle or actor ID",
443
+ name: "create",
444
+ description: "a channel, before anything posts to it (one that exists stays as it is)",
339
445
  arguments: [{
340
- name: "account",
446
+ name: "channel",
341
447
  required: true,
342
- description: "a handle or actor ID from identity"
448
+ description: "its path, such as dev/alerts"
343
449
  }],
344
450
  options: [],
345
451
  examples: [{
346
- name: "unlink an account",
347
- value: "identity unlink @ada"
452
+ name: "make a channel for alerts",
453
+ value: "channels create dev/alerts"
348
454
  }]
349
455
  }]
350
456
  },
457
+ {
458
+ group: "the board",
459
+ name: "actors",
460
+ description: "actors and their handles",
461
+ arguments: [],
462
+ options: [],
463
+ examples: [{
464
+ name: "list who has written here",
465
+ value: "actors"
466
+ }]
467
+ },
351
468
  {
352
469
  group: "messages",
353
470
  name: "read",
@@ -558,7 +675,7 @@ const commands = [
558
675
  {
559
676
  group: "messages",
560
677
  name: "typing",
561
- description: "show typing in the thread's synced platform threads (marks WhatsApp read)",
678
+ description: "show typing in the thread's synced platform threads",
562
679
  arguments: [id(), {
563
680
  name: "status",
564
681
  required: false,
@@ -1,5 +1,5 @@
1
- import { u as BoardError } from "./errors-C_5rB8Iz.mjs";
2
- import { r as compileAccess } from "./access-CQ39gqEV.mjs";
1
+ import { u as BoardError } from "./errors-CJXhhzvT.mjs";
2
+ import { r as compileAccess } from "./access-CY5xVMIK.mjs";
3
3
  //#region src/conformance/index.ts
4
4
  var Mismatch = class extends Error {};
5
5
  const show = (value) => JSON.stringify(value) ?? String(value);
@@ -18,6 +18,16 @@ async function rejects(attempt, status, code, what) {
18
18
  }
19
19
  throw new Mismatch(`${what}: expected ${status} ${code}, and it succeeded`);
20
20
  }
21
+ /** That `attempt` is refused with `status`, whatever code the board gives it. */
22
+ async function refused(attempt, status, what) {
23
+ try {
24
+ await attempt;
25
+ } catch (error) {
26
+ if (error instanceof BoardError && error.status === status) return;
27
+ throw new Mismatch(`${what}: expected ${status}, got ${error instanceof BoardError ? `${error.status} ${error.code}` : String(error)}`);
28
+ }
29
+ throw new Mismatch(`${what}: expected ${status}, and it succeeded`);
30
+ }
21
31
  /** Where a registration is woken, to put back after a check replaces it: its path (and branch) on a board that wakes by path, or its url. */
22
32
  function wokenAt(registered) {
23
33
  if (!registered?.path) return { url: registered?.url ?? null };
@@ -32,9 +42,16 @@ function isRef(value, what) {
32
42
  }
33
43
  /**
34
44
  * The groups a board may keep none of, saying so with a 501: a board on `a2()` without a scheduler
35
- * answers `no_scheduler`, and without a blob store `no_files`.
45
+ * answers `no_scheduler`, and without a blob store `no_files`; one whose `access` is one function keeps
46
+ * no grants, `no_grants`. A board that's meant to keep something and doesn't yet answers otherwise,
47
+ * and fails.
36
48
  */
37
- const OPTIONAL = /* @__PURE__ */ new Set(["schedules", "attachments"]);
49
+ const OPTIONAL = /* @__PURE__ */ new Set([
50
+ "schedules",
51
+ "grants",
52
+ "tokens",
53
+ "attachments"
54
+ ]);
38
55
  const needRoot = (ctx) => {
39
56
  check(ctx.root && ctx.reply, "posting failed, so there's nothing to read");
40
57
  return {
@@ -42,6 +59,23 @@ const needRoot = (ctx) => {
42
59
  reply: ctx.reply
43
60
  };
44
61
  };
62
+ /** A grant for a project that doesn't exist, from an environment its deployments would have: it lets nobody in. */
63
+ const grantFor = (ctx) => ({
64
+ issuer: "vercel",
65
+ claims: {
66
+ environment: "preview",
67
+ project_id: `prj_conformance${ctx.labels.conformance}`
68
+ }
69
+ });
70
+ /** Sets the check's grant, or says why it can't: changing who the board trusts takes `manage` in every channel. */
71
+ async function firstGrant(ctx) {
72
+ try {
73
+ return await ctx.board.createGrant(grantFor(ctx));
74
+ } catch (error) {
75
+ if (error instanceof BoardError && error.status === 403) return { skip: "this token can't change the board's grants" };
76
+ throw error;
77
+ }
78
+ }
45
79
  /** The sync key the sync checks register, a platform of their own, so nothing reaches a real one. */
46
80
  const SYNC_KEY = "conformance";
47
81
  /**
@@ -354,10 +388,25 @@ const CHECKS = [
354
388
  run: async (ctx) => {
355
389
  const found = (await ctx.board.channels()).find((c) => c.path === ctx.channel);
356
390
  check(found, `the channels should include ${ctx.channel}`);
357
- check(typeof found.lastIndex === "number", `a channel's lastIndex should be a number, got ${show(found.lastIndex)}`);
391
+ for (const field of [
392
+ "lastIndex",
393
+ "messages",
394
+ "threads"
395
+ ]) check(found[field] === void 0 || typeof found[field] === "number", `a channel's ${field} should be a number or left out, got ${show(found[field])}`);
358
396
  check((await ctx.board.actors()).some((a) => a.id === ctx.me.actor), `the actors should include ${ctx.me.actor}`);
359
397
  }
360
398
  },
399
+ {
400
+ group: "core",
401
+ name: "creates a channel only once: one a post made is there already",
402
+ run: async (ctx) => {
403
+ needRoot(ctx);
404
+ const { channel, created } = await ctx.board.createChannel(ctx.channel);
405
+ same(created, false, `creating ${ctx.channel}, which this run posted to`);
406
+ same(channel.path, ctx.channel, "the channel's path");
407
+ await rejects(ctx.board.createChannel("Not A Channel"), 400, "invalid_request", "creating a malformed channel");
408
+ }
409
+ },
361
410
  {
362
411
  group: "core",
363
412
  name: "answers 404 for an ID that names nothing",
@@ -411,6 +460,174 @@ const CHECKS = [
411
460
  }
412
461
  }
413
462
  },
463
+ {
464
+ group: "grants",
465
+ name: "grants a project, lists it, takes the same grant again as one, and revokes it",
466
+ run: async (ctx) => {
467
+ const asked = grantFor(ctx);
468
+ const first = await firstGrant(ctx);
469
+ if ("skip" in first) return first.skip;
470
+ try {
471
+ isRef(first.grant.id, "a grant's id");
472
+ same(first.created, true, "a new grant: created");
473
+ same(first.grant.issuer, "vercel", "the grant's issuer");
474
+ same(first.grant.claims.project_id, asked.claims.project_id, "the grant's project_id");
475
+ check(first.grant.can["*"]?.includes("post"), `a grant with no can should let them post everywhere, got ${show(first.grant.can)}`);
476
+ same(first.grant.by, ctx.me.actor, "the grant's by");
477
+ const again = await ctx.board.createGrant(asked);
478
+ same(again.created, false, "the same issuer and claims again: created");
479
+ same(again.grant.id, first.grant.id, "the same issuer and claims again: its id");
480
+ same((await ctx.board.grants()).filter((grant) => grant.id === first.grant.id).length, 1, "grants listing it");
481
+ } catch (error) {
482
+ await ctx.board.revokeGrant(first.grant.id).catch(() => {});
483
+ throw error;
484
+ }
485
+ same((await ctx.board.revokeGrant(first.grant.id)).revoked, true, "revoking it");
486
+ same((await ctx.board.revokeGrant(first.grant.id)).revoked, false, "revoking it again");
487
+ check(!(await ctx.board.grants()).some((grant) => grant.id === first.grant.id), "grants shouldn't list it once it's revoked");
488
+ }
489
+ },
490
+ {
491
+ group: "grants",
492
+ name: "changes a grant's can when it's set again",
493
+ run: async (ctx) => {
494
+ const first = await firstGrant(ctx);
495
+ if ("skip" in first) return first.skip;
496
+ const { grant } = first;
497
+ try {
498
+ const narrowed = await ctx.board.createGrant({
499
+ ...grantFor(ctx),
500
+ can: { [ctx.channel]: ["post"] }
501
+ });
502
+ same(narrowed.created, false, "setting its can: created");
503
+ same(narrowed.grant.id, grant.id, "setting its can: its id");
504
+ same(show(narrowed.grant.can), show({ [ctx.channel]: ["post"] }), "the grant's can");
505
+ } finally {
506
+ await ctx.board.revokeGrant(grant.id).catch(() => {});
507
+ }
508
+ }
509
+ },
510
+ {
511
+ group: "grants",
512
+ name: "grants a GitHub repository's workflows, and another team's project",
513
+ run: async (ctx) => {
514
+ const first = await firstGrant(ctx);
515
+ if ("skip" in first) return first.skip;
516
+ const made = [first.grant.id];
517
+ try {
518
+ const github = await ctx.board.createGrant({
519
+ issuer: "github",
520
+ claims: { repository: `acme/conformance-${ctx.labels.conformance}` },
521
+ can: { [ctx.channel]: ["post"] }
522
+ });
523
+ made.push(github.grant.id);
524
+ same(github.grant.issuer, "github", "the GitHub grant's issuer");
525
+ const theirs = await ctx.board.createGrant({
526
+ issuer: "vercel",
527
+ claims: {
528
+ environment: "production",
529
+ owner_id: `team_conformance${ctx.labels.conformance}`,
530
+ project_id: `prj_conformance${ctx.labels.conformance}x`
531
+ }
532
+ });
533
+ made.push(theirs.grant.id);
534
+ same(theirs.grant.claims.owner_id, `team_conformance${ctx.labels.conformance}`, "another team's grant's owner_id");
535
+ const listed = new Set((await ctx.board.grants()).map((grant) => grant.id));
536
+ check(made.every((id) => listed.has(id)), `grants should list ${made.join(", ")}`);
537
+ } finally {
538
+ for (const id of made) await ctx.board.revokeGrant(id).catch(() => {});
539
+ }
540
+ }
541
+ },
542
+ {
543
+ group: "grants",
544
+ name: "keeps two environments of one project as two grants, and revokes one without the other",
545
+ run: async (ctx) => {
546
+ const first = await firstGrant(ctx);
547
+ if ("skip" in first) return first.skip;
548
+ const made = [first.grant.id];
549
+ try {
550
+ const production = await ctx.board.createGrant({
551
+ ...grantFor(ctx),
552
+ claims: {
553
+ ...grantFor(ctx).claims,
554
+ environment: "production"
555
+ }
556
+ });
557
+ made.push(production.grant.id);
558
+ same(production.created, true, "another environment: created");
559
+ check(production.grant.id !== first.grant.id, "another environment should be another grant");
560
+ same((await ctx.board.revokeGrant(first.grant.id)).revoked, true, "revoking the preview grant");
561
+ const listed = (await ctx.board.grants()).map((grant) => grant.id);
562
+ check(listed.includes(production.grant.id) && !listed.includes(first.grant.id), `grants should list ${production.grant.id} and not ${first.grant.id}, got ${show(listed)}`);
563
+ } finally {
564
+ for (const id of made) await ctx.board.revokeGrant(id).catch(() => {});
565
+ }
566
+ }
567
+ },
568
+ {
569
+ group: "tokens",
570
+ name: "mints a token, shows its secret only then, lists it, and revokes it",
571
+ run: async (ctx) => {
572
+ const name = `conformance-${ctx.labels.conformance}`;
573
+ const { token, secret } = await ctx.board.createToken({
574
+ name,
575
+ can: { [ctx.channel]: ["read"] },
576
+ expires: "1h"
577
+ });
578
+ try {
579
+ isRef(token.id, "a token's id");
580
+ check(typeof secret === "string" && secret.length >= 32, `a token's secret should be a string of at least 32 characters, got ${typeof secret}`);
581
+ same(token.name, name, "the token's name");
582
+ same(show(token.can), show({ [ctx.channel]: ["read"] }), "the token's can");
583
+ const left = Date.parse(token.expiresAt) - Date.now();
584
+ check(left > 33e5 && left <= 3605e3, `a token minted for 1h should expire in an hour, got ${token.expiresAt}`);
585
+ const listed = await ctx.board.tokens();
586
+ check(listed.some((found) => found.id === token.id), `tokens should list ${token.id}`);
587
+ check(!JSON.stringify(listed).includes(secret), "tokens shouldn't show a token's secret");
588
+ } catch (error) {
589
+ await ctx.board.revokeToken(token.id).catch(() => {});
590
+ throw error;
591
+ }
592
+ same((await ctx.board.revokeToken(token.id)).revoked, true, "revoking it");
593
+ same((await ctx.board.revokeToken(token.id)).revoked, false, "revoking it again");
594
+ check(!(await ctx.board.tokens()).some((found) => found.id === token.id), "tokens shouldn't list it once it's revoked");
595
+ }
596
+ },
597
+ {
598
+ group: "tokens",
599
+ name: "lets a token do what its can says, as whoever minted it, and nothing once it's revoked",
600
+ run: async (ctx) => {
601
+ if (!ctx.withToken) return "this run can't call the board with a token's secret";
602
+ const { root } = needRoot(ctx);
603
+ const { token, secret } = await ctx.board.createToken({
604
+ name: `conformance-${ctx.labels.conformance}-reads`,
605
+ can: { [ctx.channel]: ["read"] },
606
+ expires: "1h"
607
+ });
608
+ const holder = ctx.withToken(secret);
609
+ try {
610
+ const me = await holder.me();
611
+ same(me.principal, ctx.me.principal, "a token's principal, its minter's");
612
+ const want = { [ctx.channel]: ["read"] };
613
+ check(show(me.can) === show(want), `what a token that may only read says it may do: expected ${show(want)}, got ${show(me.can)}`);
614
+ same((await holder.thread(root.id)).message.id, root.id, "a thread read with a token that may read it");
615
+ await refused(holder.post(ctx.channel, {
616
+ text: "conformance: a token that may only read",
617
+ labels: ctx.labels,
618
+ noSync: true
619
+ }).then((message) => {
620
+ ctx.posted.push(message.id);
621
+ return message;
622
+ }), 403, "a post with a token that may only read");
623
+ } catch (error) {
624
+ await ctx.board.revokeToken(token.id).catch(() => {});
625
+ throw error;
626
+ }
627
+ same((await ctx.board.revokeToken(token.id)).revoked, true, "revoking it");
628
+ await refused(holder.me(), 401, "a request with a revoked token");
629
+ }
630
+ },
414
631
  {
415
632
  group: "attachments",
416
633
  name: "takes a file, posts it, and gives it back",
@@ -703,20 +920,6 @@ const CHECKS = [
703
920
  same(thread.message.noSync, true, "a post's noSync, read back");
704
921
  same(thread.replies.find((m) => m.id === reply.id)?.noSync, true, "a reply's noSync, read back");
705
922
  }
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
- }
720
923
  }
721
924
  ];
722
925
  /** Runs the checks against `board`, in order, and reports each. It never throws for what the board answers. */
@@ -754,7 +957,8 @@ async function conformance(board, options = {}) {
754
957
  limit: 1
755
958
  })).cursor,
756
959
  me,
757
- posted: []
960
+ posted: [],
961
+ ...options.withToken ? { withToken: options.withToken } : {}
758
962
  };
759
963
  try {
760
964
  for (const { group, name, run } of CHECKS) try {
@@ -1,4 +1,4 @@
1
- import { d as Board } from "./backend-n3hajfrD.mjs";
1
+ import { f as Board } from "./backend-Bob-5CM7.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
@@ -7,7 +7,7 @@ import { d as Board } from "./backend-n3hajfrD.mjs";
7
7
  * (`conformance` by default), keeps every post on the board, and deletes what it posted.
8
8
  */
9
9
  /** What a check is about, for the report's headings. */
10
- export type CheckGroup = 'core' | 'schedules' | 'attachments' | 'watchers' | 'sync';
10
+ export type CheckGroup = 'core' | 'schedules' | 'grants' | 'tokens' | 'attachments' | 'watchers' | 'sync';
11
11
  export type CheckResult = {
12
12
  group: CheckGroup;
13
13
  name: string;
@@ -24,6 +24,12 @@ export type ConformanceReport = {
24
24
  export type ConformanceOptions = {
25
25
  /** Where it posts. Default `conformance`. */
26
26
  channel?: string;
27
+ /**
28
+ * The same board, with a board token's secret as the credential (as `BOARD_TOKEN` sends it):
29
+ * the tokens check calls it so to see that a token does only what its `can` says. Without it,
30
+ * that check is skipped.
31
+ */
32
+ withToken?: (secret: string) => Board;
27
33
  };
28
34
  /** Runs the checks against `board`, in order, and reports each. It never throws for what the board answers. */
29
35
  export declare function conformance(board: Board, options?: ConformanceOptions): Promise<ConformanceReport>;
@@ -1,2 +1,2 @@
1
- import { t as conformance } from "./conformance-C7fwIvmN.mjs";
1
+ import { t as conformance } from "./conformance-DK43HiHf.mjs";
2
2
  export { conformance };