@vercel/board 0.29.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -17
- package/dist/{a2-HUbQJ0RD.mjs → a2-DngWY2Eb.mjs} +741 -415
- package/dist/access-CY5xVMIK.mjs +242 -0
- package/dist/{access-CV96SYgr.d.mts → access-DPcrKIH_.d.mts} +6 -1
- package/dist/access.d.mts +4 -4
- package/dist/access.mjs +5 -4
- package/dist/{actors-DcJhFbwJ.mjs → actors-Bm-ojWWT.mjs} +2 -2
- package/dist/{auth-JO22iIwr.mjs → auth-CyO2aw5n.mjs} +126 -236
- package/dist/{auth-CU56Q2Xh.d.mts → auth-DLYSt_-A.d.mts} +32 -4
- package/dist/{backend-CPC_vyOo.d.mts → backend-Bob-5CM7.d.mts} +93 -42
- package/dist/backend-a2/blob-vercel.d.mts +1 -1
- package/dist/backend-a2/contract.d.mts +38 -98
- package/dist/backend-a2/contract.mjs +1 -1
- package/dist/backend-a2/scheduler-local.d.mts +1 -1
- package/dist/backend-a2/scheduler-local.mjs +1 -1
- package/dist/backend-a2/scheduler-qstash.d.mts +3 -3
- package/dist/backend-a2/scheduler-qstash.mjs +8 -8
- package/dist/backend-a2/scheduler-vercel.d.mts +3 -3
- package/dist/backend-a2/scheduler-vercel.mjs +6 -6
- package/dist/backend-a2/store-memory.d.mts +2 -2
- package/dist/backend-a2/store-memory.mjs +3 -3
- package/dist/backend-a2/store-postgres.d.mts +1 -1
- package/dist/backend-a2/store-postgres.mjs +34 -15
- package/dist/backend-a2/store-redis-http.d.mts +1 -1
- package/dist/backend-a2/store-redis-http.mjs +6 -6
- package/dist/backend-a2/store-redis.d.mts +2 -2
- package/dist/backend-a2/store-redis.mjs +5 -5
- package/dist/backend-a2/store-sqlite.d.mts +1 -1
- package/dist/backend-a2/store-sqlite.mjs +3 -3
- package/dist/backend-a2.d.mts +143 -242
- package/dist/backend-a2.mjs +2 -2
- package/dist/{backend-http-CqfKhrnn.d.mts → backend-http-BRrRbjRx.d.mts} +59 -45
- package/dist/{backend-http-CzIJMm58.mjs → backend-http-CvUKOn-m.mjs} +11 -10
- package/dist/{backend-http-Bmp0IgD3.mjs → backend-http-D8mUzuKQ.mjs} +2 -2
- package/dist/backend-http.d.mts +1 -1
- package/dist/backend-http.mjs +1 -1
- package/dist/backend-http.node.d.mts +1 -1
- package/dist/backend-http.node.mjs +1 -1
- package/dist/{backend-vercel-DQc_TaHp.mjs → backend-vercel-Ba8JDtqW.mjs} +3 -3
- package/dist/{backend-vercel-4tvcPKqX.d.mts → backend-vercel-BaSEUdwg.d.mts} +62 -47
- package/dist/{backend-vercel-BNauAs7w.mjs → backend-vercel-CmY9yZ9m.mjs} +279 -42
- package/dist/backend-vercel.d.mts +1 -1
- package/dist/backend-vercel.mjs +1 -1
- package/dist/backend-vercel.node.d.mts +1 -1
- package/dist/backend-vercel.node.mjs +1 -1
- package/dist/{blob-DmiQjrYy.d.mts → blob-Bx8QPWEd.d.mts} +2 -2
- package/dist/{blob-DdbpJqN1.mjs → blob-WbB2DJKn.mjs} +30 -11
- package/dist/{chat-provider-BWHbChcQ.d.mts → chat-provider-BChYrrA7.d.mts} +32 -28
- package/dist/{chat-provider-COtsU5jB.mjs → chat-provider-DkFp2hrh.mjs} +105 -60
- package/dist/cli-commands.d.mts +1 -1
- package/dist/cli-commands.mjs +1 -1
- package/dist/cli-embedded.d.mts +2 -2
- package/dist/cli-embedded.mjs +1 -1
- package/dist/cli.mjs +4 -4
- package/dist/{client-BqAjk0Q4.d.mts → client-COfYrgfV.d.mts} +240 -227
- package/dist/{commands-D6gvrOh3.d.mts → commands-DK4W3HCJ.d.mts} +47 -10
- package/dist/{commands-C3R1AWgm.mjs → commands-L1JSGz2Z.mjs} +212 -54
- package/dist/conformance-DK43HiHf.mjs +1000 -0
- package/dist/conformance.d.mts +8 -2
- package/dist/conformance.mjs +1 -1
- package/dist/{engine-DQxXk7dX.mjs → engine-DurYIRt8.mjs} +29 -210
- package/dist/{errors-DDktVeUa.d.mts → errors-2vGs49Db.d.mts} +1 -1
- package/dist/{client-D0Px7KzF.mjs → errors-CJXhhzvT.mjs} +132 -27
- package/dist/{provider-BaVmi4Hi.mjs → events-lujPA3gQ.mjs} +80 -17
- package/dist/{exports-BG-0Wyn-.mjs → exports-CWIdN0Ub.mjs} +195 -103
- package/dist/exports-FBOQWT7O.d.mts +5 -0
- package/dist/{folds-BDMSxBUf.mjs → folds-Cu9Q9zyQ.mjs} +3 -22
- package/dist/{folds-ZkHM4M-v.d.mts → folds-DLUzfibJ.d.mts} +15 -40
- package/dist/{index-D3VvNfh_.d.mts → index-Ho-ayyQL.d.mts} +2 -2
- package/dist/index.d.mts +7 -7
- package/dist/{index.default-rB8sJ9RX.mjs → index.default-BUcwDaAe.mjs} +1 -1
- package/dist/index.mjs +5 -6
- package/dist/index.node.d.mts +7 -7
- package/dist/index.node.mjs +3 -4
- package/dist/inspect/app.js +62 -61
- package/dist/inspect/engine.js +3 -3
- package/dist/{internal-CvsrprnU-CRY89I-b.mjs → internal-CvsrprnU-zZMpDiRY.mjs} +1 -1
- package/dist/{local-DhkrAqPP.mjs → local-C76Fy5ad.mjs} +1 -1
- package/dist/{main-CVBqe3VO.mjs → main-Dz-8komG.mjs} +251 -127
- package/dist/{middleware-CXjEeef4.mjs → middleware-BavYSDUa.mjs} +1 -1
- package/dist/{node-Zd_C-gxP.mjs → node-BgeGcwX0.mjs} +7 -3
- package/dist/{platform-B4TnJtWu-CwcTyKg2.mjs → platform-B4TnJtWu-Do3agKr5.mjs} +1 -1
- package/dist/{project-CBXlLftU.mjs → project-CQ4VUibA.mjs} +1 -1
- package/dist/{provider-DmSTp9lI.d.mts → provider-gynDUoyW.d.mts} +13 -43
- package/dist/{redis-tables-BJQVUspu.mjs → redis-tables-DHf85ixI.mjs} +40 -42
- package/dist/{reducer-CjmvZSAb-D_OSISmj.mjs → reducer-Bswr11ar-P324o9vn.mjs} +2 -2
- package/dist/{reducer-CYPHsSmd-BQIfozGi.d.mts → reducer-CYPHsSmd-BXjGiptm.d.mts} +1 -1
- package/dist/{reducers-DJzVpXwM.mjs → reducers-DcENwtwZ.mjs} +74 -77
- package/dist/{retryable-lazy-DZWmHpii-Bye-W8q7.mjs → retryable-lazy-DZWmHpii-DpTc5PIP.mjs} +1 -1
- package/dist/{scheduler-task-X1SToDjP-BVoWuInI.mjs → scheduler-task-X1SToDjP-D2Il2rln.mjs} +1 -1
- package/dist/{serve-G9RuaDSr.mjs → serve-CTmow-YH.mjs} +7 -7
- package/dist/{server-qgez0Xny-D5mLfqFQ.d.mts → server-B9gu0qi1-DI1WvcVK.d.mts} +10 -4
- package/dist/{server-BixV_byP.mjs → server-DG8D6VV0.mjs} +4 -4
- package/dist/{server-DtFrWxi3-Boa6gdWO.mjs → server-DX7mM6nm-D7N_7bug.mjs} +32 -23
- package/dist/{serving-B75Y4BF-.mjs → serving-DBFRo6U_.mjs} +1 -1
- package/dist/{session-clear-wHd5GCpZ-BnKSxReK.mjs → session-clear-XJYpmUpg-2q8l_FJR.mjs} +14 -2
- package/dist/{sql-D4Z_efJX.mjs → sql-CT5xVuGr.mjs} +2 -8
- package/dist/{sqlite-db-Bz2JkfCU.mjs → sqlite-db-0WeSOEZN.mjs} +1 -1
- package/dist/{store-D0MhBvg6-4Z2L0Kqd.d.mts → store-DEzcHZOA-B-5vhxef.d.mts} +12 -2
- package/dist/store-N8PXxDAS-DWWgv-Vz.mjs +5 -0
- package/dist/{store-codec-DwrWlgQx-CYxGW0aD.mjs → store-codec-pAG2oPgQ-SqDXlPBR.mjs} +4 -4
- package/dist/store-memory-4IBl0Ztu.mjs +2 -0
- package/dist/{store-memory-Bas9Uq5C.mjs → store-memory-B0PkVSuQ.mjs} +14 -7
- package/dist/{store-polling-BZt2Rf3D-BiEM6_HB.mjs → store-polling-BZt2Rf3D-Div-bJ_D.mjs} +2 -2
- package/dist/{store-presence-polling-JuAy2Ck2-CYpuK75X.mjs → store-presence-polling-JuAy2Ck2-Dh-g_KYM.mjs} +3 -3
- package/dist/{store-sqlite-CxxPCcCT.mjs → store-sqlite-D85t3tE7.mjs} +14 -7
- package/dist/store-sqlite-iBTYYKBj.mjs +2 -0
- package/dist/{stream-buffer-DrTyiIuo-BKy-vI14.mjs → stream-buffer-DrTyiIuo-Be10yOBR.mjs} +2 -2
- package/dist/{sync-kapso.d.mts → sync-chat-sdk.d.mts} +33 -37
- package/dist/sync-chat-sdk.mjs +44 -0
- package/dist/sync-github.d.mts +5 -18
- package/dist/sync-github.mjs +4 -70
- package/dist/sync-linear.d.mts +3 -3
- package/dist/sync-linear.mjs +6 -5
- package/dist/sync-slack.d.mts +7 -7
- package/dist/sync-slack.mjs +33 -54
- package/dist/sync.d.mts +3 -3
- package/dist/sync.mjs +2 -4
- package/dist/{tokens-CC3izSNr.mjs → tokens-Dnyd_y1n.mjs} +15 -6
- package/dist/{types-No0WdhE5.d.mts → types-CmrFjpml.d.mts} +2 -4
- package/dist/{watchers-hash-BDhLVkV5.mjs → watchers-hash-BjwlnGD9.mjs} +124 -115
- package/docs/01-board/01-mounting.md +2 -2
- package/docs/01-board/03-identity.md +5 -6
- package/docs/01-board/04-messages.md +96 -2
- package/docs/01-board/05-reading.md +2 -1
- package/docs/01-board/06-watchers.md +8 -6
- package/docs/01-board/08-middleware.md +6 -3
- package/docs/01-board/09-access.md +145 -14
- package/docs/02-sync/01-overview.md +19 -26
- package/docs/02-sync/02-functions.md +8 -9
- package/docs/02-sync/03-slack.md +5 -5
- package/docs/02-sync/04-github.md +2 -2
- package/docs/02-sync/06-providers.md +91 -62
- package/docs/03-guides/01-coding-agents.md +1 -1
- package/docs/03-guides/03-ci.md +7 -4
- package/docs/04-reference/01-cli.md +109 -51
- package/docs/04-reference/02-board.md +76 -28
- package/docs/04-reference/03-http.md +76 -38
- package/docs/04-reference/04-log.md +8 -11
- package/docs/04-reference/05-conformance.md +47 -7
- package/docs/index.md +2 -2
- package/package.json +7 -10
- package/sql/postgres/board.sql +1 -1
- package/sql/postgres/schema.sql +1 -1
- package/dist/conformance-DlnfuzEB.mjs +0 -556
- package/dist/errors-CusN4x2l.mjs +0 -88
- package/dist/events-qf5XG5VQ.mjs +0 -75
- package/dist/exports-mhAYiU4i.d.mts +0 -5
- package/dist/store-N8PXxDAS-BWBgzBHp.mjs +0 -5
- package/dist/store-memory-ZLiZbH3R.mjs +0 -2
- package/dist/store-sqlite-BmwL3pUR.mjs +0 -2
- package/dist/sync-kapso.mjs +0 -89
- package/dist/sync.chat.d.mts +0 -2
- package/dist/sync.chat.mjs +0 -2
- package/dist/users-yryHKFMo.mjs +0 -42
- package/docs/02-sync/05-whatsapp.md +0 -152
|
@@ -0,0 +1,1000 @@
|
|
|
1
|
+
import { u as BoardError } from "./errors-CJXhhzvT.mjs";
|
|
2
|
+
import { r as compileAccess } from "./access-CY5xVMIK.mjs";
|
|
3
|
+
//#region src/conformance/index.ts
|
|
4
|
+
var Mismatch = class extends Error {};
|
|
5
|
+
const show = (value) => JSON.stringify(value) ?? String(value);
|
|
6
|
+
function check(condition, message) {
|
|
7
|
+
if (!condition) throw new Mismatch(message);
|
|
8
|
+
}
|
|
9
|
+
function same(actual, expected, what) {
|
|
10
|
+
if (actual !== expected) throw new Mismatch(`${what}: expected ${show(expected)}, got ${show(actual)}`);
|
|
11
|
+
}
|
|
12
|
+
async function rejects(attempt, status, code, what) {
|
|
13
|
+
try {
|
|
14
|
+
await attempt;
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if (error instanceof BoardError && error.status === status && error.code === code) return;
|
|
17
|
+
throw new Mismatch(`${what}: expected ${status} ${code}, got ${error instanceof BoardError ? `${error.status} ${error.code}` : String(error)}`);
|
|
18
|
+
}
|
|
19
|
+
throw new Mismatch(`${what}: expected ${status} ${code}, and it succeeded`);
|
|
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
|
+
}
|
|
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. */
|
|
32
|
+
function wokenAt(registered) {
|
|
33
|
+
if (!registered?.path) return { url: registered?.url ?? null };
|
|
34
|
+
return {
|
|
35
|
+
path: registered.path,
|
|
36
|
+
...registered.branch ? { branch: registered.branch } : {}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** An ID or cursor as the protocol shapes them: a string of 1 to 128 characters. */
|
|
40
|
+
function isRef(value, what) {
|
|
41
|
+
check(typeof value === "string" && value.length >= 1 && value.length <= 128, `${what} should be a string of 1 to 128 characters, got ${show(value)}`);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The groups a board may keep none of, saying so with a 501: a board on `a2()` without a scheduler
|
|
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.
|
|
48
|
+
*/
|
|
49
|
+
const OPTIONAL = /* @__PURE__ */ new Set([
|
|
50
|
+
"schedules",
|
|
51
|
+
"grants",
|
|
52
|
+
"tokens",
|
|
53
|
+
"attachments"
|
|
54
|
+
]);
|
|
55
|
+
const needRoot = (ctx) => {
|
|
56
|
+
check(ctx.root && ctx.reply, "posting failed, so there's nothing to read");
|
|
57
|
+
return {
|
|
58
|
+
root: ctx.root,
|
|
59
|
+
reply: ctx.reply
|
|
60
|
+
};
|
|
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
|
+
}
|
|
79
|
+
/** The sync key the sync checks register, a platform of their own, so nothing reaches a real one. */
|
|
80
|
+
const SYNC_KEY = "conformance";
|
|
81
|
+
/**
|
|
82
|
+
* Registers a sync on `SYNC_KEY` for a check, and says where to put the token's registration back
|
|
83
|
+
* after: or why it can't, since registering replaces the token's watchers.
|
|
84
|
+
*/
|
|
85
|
+
async function registerSync(ctx) {
|
|
86
|
+
const registered = (await ctx.board.watchers()).find((registration) => registration.owner === ctx.me.principal);
|
|
87
|
+
if (registered && (Object.keys(registered.watch).length > 0 || (registered.sync?.length ?? 0) > 0)) return { skip: "this token has watchers or a sync registered here, which registering would replace" };
|
|
88
|
+
const where = wokenAt(registered);
|
|
89
|
+
try {
|
|
90
|
+
await ctx.board.register({
|
|
91
|
+
url: null,
|
|
92
|
+
watch: {},
|
|
93
|
+
sync: [{
|
|
94
|
+
key: SYNC_KEY,
|
|
95
|
+
platform: SYNC_KEY
|
|
96
|
+
}]
|
|
97
|
+
});
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (error instanceof BoardError && error.status === 403) return { skip: "this token can't register a sync here" };
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
return { where };
|
|
103
|
+
}
|
|
104
|
+
async function post(ctx, text, extra = {}) {
|
|
105
|
+
const message = await ctx.board.post(ctx.channel, {
|
|
106
|
+
text,
|
|
107
|
+
labels: ctx.labels,
|
|
108
|
+
noSync: true,
|
|
109
|
+
...extra
|
|
110
|
+
});
|
|
111
|
+
ctx.posted.push(message.id);
|
|
112
|
+
return message;
|
|
113
|
+
}
|
|
114
|
+
async function reply(ctx, re, text, extra = {}) {
|
|
115
|
+
const message = await ctx.board.reply(re, {
|
|
116
|
+
text,
|
|
117
|
+
noSync: true,
|
|
118
|
+
...extra
|
|
119
|
+
});
|
|
120
|
+
ctx.posted.push(message.id);
|
|
121
|
+
return message;
|
|
122
|
+
}
|
|
123
|
+
const CHECKS = [
|
|
124
|
+
{
|
|
125
|
+
group: "core",
|
|
126
|
+
name: "posts a message, and reads it back by its ID",
|
|
127
|
+
run: async (ctx) => {
|
|
128
|
+
const root = await post(ctx, "conformance: a root");
|
|
129
|
+
isRef(root.id, "a message's id");
|
|
130
|
+
check(typeof root.version === "number", `a message's version should be a number, got ${show(root.version)}`);
|
|
131
|
+
const thread = await ctx.board.thread(root.id);
|
|
132
|
+
same(thread.message.id, root.id, "the thread read by that id");
|
|
133
|
+
same(thread.message.text, "conformance: a root", "its text");
|
|
134
|
+
ctx.root = root;
|
|
135
|
+
const reply = await ctx.board.reply(root.id, {
|
|
136
|
+
text: "conformance: a reply",
|
|
137
|
+
noSync: true
|
|
138
|
+
});
|
|
139
|
+
ctx.posted.push(reply.id);
|
|
140
|
+
ctx.reply = reply;
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
group: "core",
|
|
145
|
+
name: "puts a reply in its root's thread",
|
|
146
|
+
run: async (ctx) => {
|
|
147
|
+
const { root, reply } = needRoot(ctx);
|
|
148
|
+
same(reply.re, root.id, "the reply's re");
|
|
149
|
+
check((await ctx.board.thread(root.id)).replies.some((m) => m.id === reply.id), `the thread's replies should include ${reply.id}`);
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
group: "core",
|
|
154
|
+
name: "replies after a message only while it's the thread's newest, one of any that race",
|
|
155
|
+
run: async (ctx) => {
|
|
156
|
+
const root = await post(ctx, "conformance: a thread to reply after");
|
|
157
|
+
const first = await reply(ctx, root.id, "conformance: after the root", { after: root.id });
|
|
158
|
+
await rejects(reply(ctx, root.id, "conformance: late", { after: root.id }), 409, "newer_message", "a reply after the root, once another came after it");
|
|
159
|
+
same((await ctx.board.thread(root.id)).replies.length, 1, "the replies after a refused one");
|
|
160
|
+
const deleted = await reply(ctx, root.id, "conformance: deleted", { after: first.id });
|
|
161
|
+
await ctx.board.delete(deleted.id);
|
|
162
|
+
await rejects(reply(ctx, root.id, "conformance: after a deleted one", { after: first.id }), 409, "newer_message", "a reply after a message a deleted one came after");
|
|
163
|
+
const racing = await Promise.allSettled([
|
|
164
|
+
1,
|
|
165
|
+
2,
|
|
166
|
+
3
|
|
167
|
+
].map((n) => reply(ctx, root.id, `conformance: racing ${n}`, { after: deleted.id })));
|
|
168
|
+
same(racing.filter((result) => result.status === "fulfilled").length, 1, "the replies that landed of three racing after the same message");
|
|
169
|
+
for (const result of racing) {
|
|
170
|
+
if (result.status === "fulfilled") continue;
|
|
171
|
+
const error = result.reason;
|
|
172
|
+
check(error instanceof BoardError && error.status === 409 && error.code === "newer_message", `a reply that lost the race should be 409 newer_message, got ${error instanceof BoardError ? `${error.status} ${error.code}` : String(error)}`);
|
|
173
|
+
}
|
|
174
|
+
same((await ctx.board.thread(root.id)).replies.length, 3, "the replies after the race, a deleted one included");
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
group: "core",
|
|
179
|
+
name: "posts once for a client ID, however many times it is sent",
|
|
180
|
+
run: async (ctx) => {
|
|
181
|
+
const id = `conformance-${ctx.labels.conformance}`;
|
|
182
|
+
const first = await post(ctx, "conformance: once", { id });
|
|
183
|
+
same((await post(ctx, "conformance: once", { id })).id, first.id, "the second post with the same client id");
|
|
184
|
+
same((await ctx.board.posted(id)).id, first.id, "the post found by its client id");
|
|
185
|
+
await rejects(ctx.board.posted(`${id}-never`), 404, "post_not_found", "a client id nothing was posted with");
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
group: "core",
|
|
190
|
+
name: "raises the version with each edit, reaction, and label",
|
|
191
|
+
run: async (ctx) => {
|
|
192
|
+
const { root } = needRoot(ctx);
|
|
193
|
+
const version = async () => (await ctx.board.thread(root.id)).message;
|
|
194
|
+
let before = await version();
|
|
195
|
+
for (const [what, change] of [
|
|
196
|
+
["an edit", () => ctx.board.edit(root.id, "conformance: an edited root")],
|
|
197
|
+
["a reaction", () => ctx.board.react(root.id, "eyes")],
|
|
198
|
+
["a label", () => ctx.board.label(root.id, { set: { state: "open" } })]
|
|
199
|
+
]) {
|
|
200
|
+
await change();
|
|
201
|
+
const after = await version();
|
|
202
|
+
check(after.version > before.version, `${what} should raise the version past ${before.version}, got ${after.version}`);
|
|
203
|
+
before = after;
|
|
204
|
+
}
|
|
205
|
+
same(before.text, "conformance: an edited root", "the edited text");
|
|
206
|
+
check(before.editedAt !== null, "an edited message should have editedAt");
|
|
207
|
+
check(before.reactions.eyes?.includes(ctx.me.actor), `reactions.eyes should include ${ctx.me.actor}, got ${show(before.reactions)}`);
|
|
208
|
+
same(before.labels.state, "open", "the label set");
|
|
209
|
+
await ctx.board.label(root.id, { unset: ["state"] });
|
|
210
|
+
same((await version()).labels.state, void 0, "the label unset");
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
group: "core",
|
|
215
|
+
name: "labels a message only while its labels are as the write says, one of any that race",
|
|
216
|
+
run: async (ctx) => {
|
|
217
|
+
const message = await post(ctx, "conformance: a message to claim");
|
|
218
|
+
const labels = async () => (await ctx.board.thread(message.id)).message.labels;
|
|
219
|
+
const racing = await Promise.allSettled([
|
|
220
|
+
1,
|
|
221
|
+
2,
|
|
222
|
+
3
|
|
223
|
+
].map((n) => ctx.board.label(message.id, {
|
|
224
|
+
set: { owner: `agent-${n}` },
|
|
225
|
+
if: { owner: null }
|
|
226
|
+
})));
|
|
227
|
+
const landed = racing.flatMap((result, n) => result.status === "fulfilled" ? [`agent-${n + 1}`] : []);
|
|
228
|
+
same(landed.length, 1, "the claims that landed of three racing where no one had one");
|
|
229
|
+
for (const result of racing) {
|
|
230
|
+
if (result.status === "fulfilled") continue;
|
|
231
|
+
const error = result.reason;
|
|
232
|
+
check(error instanceof BoardError && error.status === 409 && error.code === "label_conflict", `a claim that lost the race should be 409 label_conflict, got ${error instanceof BoardError ? `${error.status} ${error.code}` : String(error)}`);
|
|
233
|
+
}
|
|
234
|
+
same((await labels()).owner, landed[0], "the owner, after the race");
|
|
235
|
+
await rejects(ctx.board.label(message.id, {
|
|
236
|
+
set: { owner: "someone" },
|
|
237
|
+
if: { owner: "no-one" }
|
|
238
|
+
}), 409, "label_conflict", "a label write from a value the label doesn't have");
|
|
239
|
+
same((await labels()).owner, landed[0], "the owner, after a write from the wrong value");
|
|
240
|
+
await ctx.board.label(message.id, {
|
|
241
|
+
unset: ["owner"],
|
|
242
|
+
if: { owner: landed[0] }
|
|
243
|
+
});
|
|
244
|
+
same((await labels()).owner, void 0, "the owner, released from the value it had");
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
group: "core",
|
|
249
|
+
name: "edits and deletes a message only at the revision it was read at, which only edits and deletes change",
|
|
250
|
+
run: async (ctx) => {
|
|
251
|
+
const message = await post(ctx, "conformance: a message to edit");
|
|
252
|
+
const read = async () => (await ctx.board.thread(message.id)).message;
|
|
253
|
+
const first = (await read()).revision;
|
|
254
|
+
check(typeof first === "number", `a message's revision should be a number, got ${show(first)}`);
|
|
255
|
+
await ctx.board.react(message.id, "eyes");
|
|
256
|
+
await ctx.board.label(message.id, { set: { state: "open" } });
|
|
257
|
+
same((await read()).revision, first, "the revision after a reaction and a label");
|
|
258
|
+
await ctx.board.edit(message.id, "conformance: edited", { revision: first });
|
|
259
|
+
const second = (await read()).revision;
|
|
260
|
+
check(second !== first, `an edit should change the revision from ${first}`);
|
|
261
|
+
await rejects(ctx.board.edit(message.id, "conformance: stale", { revision: first }), 409, "revision_conflict", "an edit at a revision an edit came after");
|
|
262
|
+
await rejects(ctx.board.delete(message.id, { revision: first }), 409, "revision_conflict", "a delete at a revision an edit came after");
|
|
263
|
+
const after = await read();
|
|
264
|
+
check(after.text === "conformance: edited" && !after.deleted, `refused writes should leave it as it was, got ${show({
|
|
265
|
+
text: after.text,
|
|
266
|
+
deleted: after.deleted
|
|
267
|
+
})}`);
|
|
268
|
+
const racing = await Promise.allSettled([
|
|
269
|
+
1,
|
|
270
|
+
2,
|
|
271
|
+
3
|
|
272
|
+
].map((n) => ctx.board.edit(message.id, `conformance: racing ${n}`, { revision: second })));
|
|
273
|
+
same(racing.filter((result) => result.status === "fulfilled").length, 1, "the edits that landed of three racing at the same revision");
|
|
274
|
+
for (const result of racing) {
|
|
275
|
+
if (result.status === "fulfilled") continue;
|
|
276
|
+
const error = result.reason;
|
|
277
|
+
check(error instanceof BoardError && error.status === 409 && error.code === "revision_conflict", `an edit that lost the race should be 409 revision_conflict, got ${error instanceof BoardError ? `${error.status} ${error.code}` : String(error)}`);
|
|
278
|
+
}
|
|
279
|
+
const last = (await read()).revision;
|
|
280
|
+
await ctx.board.delete(message.id, { revision: last });
|
|
281
|
+
check((await read()).deleted, "a delete at the revision it has should delete it");
|
|
282
|
+
await rejects(ctx.board.edit(message.id, "conformance: too late", { revision: last }), 409, "revision_conflict", "an edit at a revision a delete came after");
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
group: "core",
|
|
287
|
+
name: "matches a label on the message or its thread's root",
|
|
288
|
+
run: async (ctx) => {
|
|
289
|
+
const { reply } = needRoot(ctx);
|
|
290
|
+
const { messages } = await ctx.board.messages({
|
|
291
|
+
channel: ctx.channel,
|
|
292
|
+
label: ctx.labels
|
|
293
|
+
});
|
|
294
|
+
check(messages.some((m) => m.id === reply.id), "a reply should match its root's label");
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
group: "core",
|
|
299
|
+
name: "reads a channel after a cursor or a message ID, oldest first",
|
|
300
|
+
run: async (ctx) => {
|
|
301
|
+
const { root, reply } = needRoot(ctx);
|
|
302
|
+
const page = await ctx.board.messages({
|
|
303
|
+
channel: ctx.channel,
|
|
304
|
+
label: ctx.labels,
|
|
305
|
+
after: ctx.start
|
|
306
|
+
});
|
|
307
|
+
isRef(page.cursor, "a read's cursor");
|
|
308
|
+
const ids = page.messages.map((m) => m.id);
|
|
309
|
+
check(ids.indexOf(root.id) !== -1 && ids.indexOf(root.id) < ids.indexOf(reply.id), `the root should come before its reply, got ${show(ids)}`);
|
|
310
|
+
const later = await ctx.board.messages({
|
|
311
|
+
channel: ctx.channel,
|
|
312
|
+
label: ctx.labels,
|
|
313
|
+
after: root.id
|
|
314
|
+
});
|
|
315
|
+
check(!later.messages.some((m) => m.id === root.id), "reading after a message should leave it out");
|
|
316
|
+
check(later.messages.some((m) => m.id === reply.id), "reading after a message should include what came after it");
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
group: "core",
|
|
321
|
+
name: "lists threads, each with its reply count and latest reply",
|
|
322
|
+
run: async (ctx) => {
|
|
323
|
+
const { root, reply } = needRoot(ctx);
|
|
324
|
+
const { threads } = await ctx.board.threads({
|
|
325
|
+
channel: ctx.channel,
|
|
326
|
+
label: ctx.labels
|
|
327
|
+
});
|
|
328
|
+
const found = threads.find((t) => t.message.id === root.id);
|
|
329
|
+
check(found, `the threads should include ${root.id}`);
|
|
330
|
+
same(found.replies, 1, "its reply count");
|
|
331
|
+
same(found.last?.id, reply.id, "its latest reply");
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
group: "core",
|
|
336
|
+
name: "reads events after a cursor, each with a cursor of its own",
|
|
337
|
+
run: async (ctx) => {
|
|
338
|
+
const { root } = needRoot(ctx);
|
|
339
|
+
const page = await ctx.board.poll({
|
|
340
|
+
channel: ctx.channel,
|
|
341
|
+
after: ctx.start
|
|
342
|
+
});
|
|
343
|
+
isRef(page.cursor, "the events' cursor");
|
|
344
|
+
const posted = page.events.find((event) => event.type === "message.posted" && event.message?.id === root.id);
|
|
345
|
+
check(posted, `the events should include ${root.id} being posted`);
|
|
346
|
+
isRef(posted.cursor, "an event's cursor");
|
|
347
|
+
check(!(await ctx.board.poll({
|
|
348
|
+
channel: ctx.channel,
|
|
349
|
+
after: page.cursor
|
|
350
|
+
})).events.some((event) => event.message?.id === root.id), "reading on from the cursor should not repeat what it read");
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
group: "core",
|
|
355
|
+
name: "holds a long-poll open until the next event",
|
|
356
|
+
run: async (ctx) => {
|
|
357
|
+
const { cursor } = await ctx.board.poll({
|
|
358
|
+
channel: ctx.channel,
|
|
359
|
+
label: ctx.labels
|
|
360
|
+
});
|
|
361
|
+
const waiting = ctx.board.poll({
|
|
362
|
+
channel: ctx.channel,
|
|
363
|
+
label: ctx.labels,
|
|
364
|
+
after: cursor,
|
|
365
|
+
wait: 10
|
|
366
|
+
});
|
|
367
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
368
|
+
const later = await post(ctx, "conformance: while it waits");
|
|
369
|
+
check((await waiting).events.some((event) => event.message?.id === later.id), "the long-poll should return the post made while it waited");
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
group: "core",
|
|
374
|
+
name: "deletes a message, leaving a tombstone",
|
|
375
|
+
run: async (ctx) => {
|
|
376
|
+
const message = await post(ctx, "conformance: to delete");
|
|
377
|
+
await ctx.board.delete(message.id);
|
|
378
|
+
const { message: after } = await ctx.board.thread(message.id);
|
|
379
|
+
check(after.deleted && after.text === "", `a deleted message should be deleted with no text, got ${show({
|
|
380
|
+
deleted: after.deleted,
|
|
381
|
+
text: after.text
|
|
382
|
+
})}`);
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
group: "core",
|
|
387
|
+
name: "lists the channel and you in it",
|
|
388
|
+
run: async (ctx) => {
|
|
389
|
+
const found = (await ctx.board.channels()).find((c) => c.path === ctx.channel);
|
|
390
|
+
check(found, `the channels should include ${ctx.channel}`);
|
|
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])}`);
|
|
396
|
+
check((await ctx.board.actors()).some((a) => a.id === ctx.me.actor), `the actors should include ${ctx.me.actor}`);
|
|
397
|
+
}
|
|
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
|
+
},
|
|
410
|
+
{
|
|
411
|
+
group: "core",
|
|
412
|
+
name: "answers 404 for an ID that names nothing",
|
|
413
|
+
run: async (ctx) => {
|
|
414
|
+
await rejects(ctx.board.thread(`no-such-message-${ctx.labels.conformance}`), 404, "message_not_found", "reading a message that was never posted");
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
group: "core",
|
|
419
|
+
name: "answers 400 for a request that does not validate",
|
|
420
|
+
run: async (ctx) => {
|
|
421
|
+
await rejects(ctx.board.post("Not A Channel", {
|
|
422
|
+
text: "conformance",
|
|
423
|
+
noSync: true
|
|
424
|
+
}), 400, "invalid_request", "posting to a malformed channel");
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
group: "schedules",
|
|
429
|
+
name: "schedules a reply, lists it, and cancels it",
|
|
430
|
+
run: async (ctx) => {
|
|
431
|
+
const { root } = needRoot(ctx);
|
|
432
|
+
const schedule = await ctx.board.schedule({ re: root.id }, {
|
|
433
|
+
text: "conformance: later",
|
|
434
|
+
noSync: true,
|
|
435
|
+
in: "1d"
|
|
436
|
+
});
|
|
437
|
+
isRef(schedule.id, "a schedule's id");
|
|
438
|
+
same(schedule.re, root.id, "the schedule's re");
|
|
439
|
+
same(schedule.state, "pending", "the schedule's state");
|
|
440
|
+
check((await ctx.board.schedules({ re: root.id })).some((s) => s.id === schedule.id), `the schedules should include ${schedule.id}`);
|
|
441
|
+
const { schedule: canceled } = await ctx.board.cancelSchedule(schedule.id);
|
|
442
|
+
same(canceled.state, "canceled", "the canceled schedule");
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
group: "schedules",
|
|
447
|
+
name: "keeps noSync on a schedule, so its sends stay off every platform",
|
|
448
|
+
run: async (ctx) => {
|
|
449
|
+
const { root } = needRoot(ctx);
|
|
450
|
+
const schedule = await ctx.board.schedule({ re: root.id }, {
|
|
451
|
+
text: "conformance: later, on the board only",
|
|
452
|
+
noSync: true,
|
|
453
|
+
in: "1d"
|
|
454
|
+
});
|
|
455
|
+
try {
|
|
456
|
+
same(schedule.noSync, true, "the schedule's noSync");
|
|
457
|
+
same((await ctx.board.schedules({ re: root.id })).find((s) => s.id === schedule.id)?.noSync, true, "the listed schedule's noSync");
|
|
458
|
+
} finally {
|
|
459
|
+
await ctx.board.cancelSchedule(schedule.id).catch(() => {});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
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
|
+
},
|
|
631
|
+
{
|
|
632
|
+
group: "attachments",
|
|
633
|
+
name: "takes a file, posts it, and gives it back",
|
|
634
|
+
run: async (ctx) => {
|
|
635
|
+
const bytes = new TextEncoder().encode(`conformance ${ctx.labels.conformance}\n`);
|
|
636
|
+
const attachment = await ctx.board.upload({
|
|
637
|
+
data: bytes,
|
|
638
|
+
name: "conformance.txt",
|
|
639
|
+
type: "text/plain"
|
|
640
|
+
});
|
|
641
|
+
check((await post(ctx, "conformance: a file", { attachments: [attachment.id] })).attachments.some((a) => a.id === attachment.id), "the post should carry the file");
|
|
642
|
+
const response = await ctx.board.download(attachment.id);
|
|
643
|
+
same(new TextDecoder().decode(await response.arrayBuffer()), new TextDecoder().decode(bytes), "the file's bytes");
|
|
644
|
+
}
|
|
645
|
+
},
|
|
646
|
+
{
|
|
647
|
+
group: "watchers",
|
|
648
|
+
name: "matches a registered tail, hands the match to a pull, and takes its ack",
|
|
649
|
+
run: async (ctx) => {
|
|
650
|
+
const registered = (await ctx.board.watchers()).find((r) => r.owner === ctx.me.principal);
|
|
651
|
+
if (registered && (Object.keys(registered.watch).length > 0 || (registered.sync?.length ?? 0) > 0)) return "this token has watchers or a sync registered here, and checking would replace them";
|
|
652
|
+
const where = wokenAt(registered);
|
|
653
|
+
await ctx.board.register({
|
|
654
|
+
url: null,
|
|
655
|
+
watch: { conformance: { tail: `${ctx.channel} --label conformance=${ctx.labels.conformance}` } }
|
|
656
|
+
});
|
|
657
|
+
try {
|
|
658
|
+
const message = await post(ctx, "conformance: for a watcher");
|
|
659
|
+
const deadline = Date.now() + 1e4;
|
|
660
|
+
let match;
|
|
661
|
+
while (!match && Date.now() < deadline) match = (await ctx.board.matches({ wait: 5 })).find((m) => m.event.message?.id === message.id);
|
|
662
|
+
check(match, "a pull should hand out the match for the post");
|
|
663
|
+
same(match.watcher, "conformance", "the match's watcher");
|
|
664
|
+
await ctx.board.ack(match.id, { lease: match.lease });
|
|
665
|
+
await rejects(ctx.board.ack(match.id, { lease: match.lease }), 404, "no_match", "acking a match twice");
|
|
666
|
+
} finally {
|
|
667
|
+
await ctx.board.register({
|
|
668
|
+
...where,
|
|
669
|
+
watch: {}
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
},
|
|
674
|
+
{
|
|
675
|
+
group: "watchers",
|
|
676
|
+
name: "wakes by path only where its discovery says so, and only a Vercel project",
|
|
677
|
+
run: async (ctx) => {
|
|
678
|
+
const registered = (await ctx.board.watchers()).find((r) => r.owner === ctx.me.principal);
|
|
679
|
+
if (registered && (Object.keys(registered.watch).length > 0 || (registered.sync?.length ?? 0) > 0)) return "this token has watchers or a sync registered here, and checking would replace them";
|
|
680
|
+
const byPath = (await ctx.board.discovery()).wake_by_path;
|
|
681
|
+
const environment = /^vercel:project:[^:]+:(.+)$/.exec(ctx.me.principal)?.[1] ?? null;
|
|
682
|
+
if (byPath && environment !== null && environment !== "production" && environment !== "preview") return `a path wakes production or a preview, and this token is ${environment}'s`;
|
|
683
|
+
const where = wokenAt(registered);
|
|
684
|
+
const branch = environment === "preview" ? { branch: "conformance" } : {};
|
|
685
|
+
const attempt = () => ctx.board.register({
|
|
686
|
+
path: "/conformance",
|
|
687
|
+
...branch,
|
|
688
|
+
watch: {}
|
|
689
|
+
});
|
|
690
|
+
try {
|
|
691
|
+
if (!byPath) await rejects(attempt(), 501, "no_wake_by_path", "a path, on a board that wakes only urls");
|
|
692
|
+
else if (environment === null) await rejects(attempt(), 403, "not_a_project", "a path, from a token that isn't a Vercel project's");
|
|
693
|
+
else {
|
|
694
|
+
const view = await attempt();
|
|
695
|
+
same(view.path, "/conformance", "the registration's path");
|
|
696
|
+
same(view.url, null, "the registration's url");
|
|
697
|
+
same(view.branch ?? null, environment === "preview" ? "conformance" : null, "the registration's branch");
|
|
698
|
+
}
|
|
699
|
+
} finally {
|
|
700
|
+
await ctx.board.register({
|
|
701
|
+
...where,
|
|
702
|
+
watch: {}
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
},
|
|
707
|
+
{
|
|
708
|
+
group: "sync",
|
|
709
|
+
name: "keeps sync's records: an import as a platform author, its lookups by platform ID, a receipt, and where it's synced",
|
|
710
|
+
run: async (ctx) => {
|
|
711
|
+
const key = SYNC_KEY;
|
|
712
|
+
const thread = `${key}:${ctx.labels.conformance}:1`;
|
|
713
|
+
const author = {
|
|
714
|
+
id: `${key}:user:${ctx.labels.conformance}`,
|
|
715
|
+
handle: "conformance",
|
|
716
|
+
name: "Conformance",
|
|
717
|
+
avatar: null,
|
|
718
|
+
isBot: false
|
|
719
|
+
};
|
|
720
|
+
const origin = {
|
|
721
|
+
thread,
|
|
722
|
+
message: "1"
|
|
723
|
+
};
|
|
724
|
+
const registered = await registerSync(ctx);
|
|
725
|
+
if ("skip" in registered) return registered.skip;
|
|
726
|
+
const { where } = registered;
|
|
727
|
+
let imported;
|
|
728
|
+
try {
|
|
729
|
+
imported = await ctx.board.syncImport({
|
|
730
|
+
op: "post",
|
|
731
|
+
author,
|
|
732
|
+
from: key,
|
|
733
|
+
channel: ctx.channel,
|
|
734
|
+
text: "conformance: from a platform",
|
|
735
|
+
labels: ctx.labels,
|
|
736
|
+
noSync: true,
|
|
737
|
+
origin: {
|
|
738
|
+
channel: `${key}:${ctx.labels.conformance}`,
|
|
739
|
+
thread,
|
|
740
|
+
subthread: null,
|
|
741
|
+
message: "1",
|
|
742
|
+
context: null
|
|
743
|
+
},
|
|
744
|
+
id: `conformance-sync-${ctx.labels.conformance}`
|
|
745
|
+
});
|
|
746
|
+
} catch (error) {
|
|
747
|
+
await ctx.board.register({
|
|
748
|
+
...where,
|
|
749
|
+
watch: {}
|
|
750
|
+
}).catch(() => {});
|
|
751
|
+
if (error instanceof BoardError && error.status === 403) return `this token can't sync in ${ctx.channel}`;
|
|
752
|
+
throw error;
|
|
753
|
+
}
|
|
754
|
+
const id = imported.message;
|
|
755
|
+
isRef(id, "an import's message");
|
|
756
|
+
try {
|
|
757
|
+
const message = (await ctx.board.thread(id)).message;
|
|
758
|
+
same(message.by.actor, author.id, "the import's author");
|
|
759
|
+
same(message.from, key, "the import's from");
|
|
760
|
+
same(message.origin?.thread, thread, "the import's origin");
|
|
761
|
+
const found = await ctx.board.syncFind({
|
|
762
|
+
thread,
|
|
763
|
+
message: "1"
|
|
764
|
+
});
|
|
765
|
+
same(found.message, id, "the message found by its platform IDs");
|
|
766
|
+
check(found.thread !== null, "the thread found by its platform thread");
|
|
767
|
+
same((await ctx.board.syncThread(found.thread)).root, id, "the synced thread's root");
|
|
768
|
+
same((await ctx.board.syncImport({
|
|
769
|
+
op: "post",
|
|
770
|
+
author,
|
|
771
|
+
from: key,
|
|
772
|
+
channel: ctx.channel,
|
|
773
|
+
text: "conformance: from a platform",
|
|
774
|
+
noSync: true,
|
|
775
|
+
id: `conformance-sync-${ctx.labels.conformance}`
|
|
776
|
+
})).message, id, "the same import, sent again");
|
|
777
|
+
await rejects(ctx.board.syncImport({
|
|
778
|
+
op: "post",
|
|
779
|
+
author,
|
|
780
|
+
from: key,
|
|
781
|
+
channel: ctx.channel,
|
|
782
|
+
text: "conformance: a second root",
|
|
783
|
+
noSync: true,
|
|
784
|
+
origin: {
|
|
785
|
+
thread,
|
|
786
|
+
subthread: null,
|
|
787
|
+
message: "3",
|
|
788
|
+
context: null
|
|
789
|
+
}
|
|
790
|
+
}), 409, "already_synced", "an import from a platform thread another board thread syncs with");
|
|
791
|
+
const external = {
|
|
792
|
+
thread: `${key}:${ctx.labels.conformance}:2`,
|
|
793
|
+
message: "2"
|
|
794
|
+
};
|
|
795
|
+
await ctx.board.syncReceipt({
|
|
796
|
+
kind: "thread",
|
|
797
|
+
message: id,
|
|
798
|
+
target: external.thread,
|
|
799
|
+
targetKind: "thread",
|
|
800
|
+
cause: "request"
|
|
801
|
+
});
|
|
802
|
+
await ctx.board.syncReceipt({
|
|
803
|
+
kind: "message",
|
|
804
|
+
message: id,
|
|
805
|
+
target: external.thread,
|
|
806
|
+
external
|
|
807
|
+
});
|
|
808
|
+
same(JSON.stringify((await ctx.board.syncCopies([id]))[id]?.[external.thread]), JSON.stringify(external), "the copy its receipt recorded");
|
|
809
|
+
const { targets } = await ctx.board.sync(id);
|
|
810
|
+
check(targets.some((row) => row.target === external.thread && row.message === external.message), `where the thread is synced should show the copy its receipt recorded, got ${show(targets)}`);
|
|
811
|
+
await rejects(ctx.board.linked(`https://conformance.invalid/${ctx.labels.conformance}`), 404, "message_not_found", "a link no sync here knows");
|
|
812
|
+
} finally {
|
|
813
|
+
await ctx.board.syncImport({
|
|
814
|
+
op: "delete",
|
|
815
|
+
author,
|
|
816
|
+
from: key,
|
|
817
|
+
origin
|
|
818
|
+
}).catch(() => {});
|
|
819
|
+
await ctx.board.syncImport({
|
|
820
|
+
op: "delete",
|
|
821
|
+
author,
|
|
822
|
+
from: key,
|
|
823
|
+
origin: {
|
|
824
|
+
thread,
|
|
825
|
+
message: "3"
|
|
826
|
+
}
|
|
827
|
+
}).catch(() => {});
|
|
828
|
+
await ctx.board.register({
|
|
829
|
+
...where,
|
|
830
|
+
watch: {}
|
|
831
|
+
}).catch(() => {});
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
},
|
|
835
|
+
{
|
|
836
|
+
group: "sync",
|
|
837
|
+
name: "queues a platform thread's events in its sync's inbox once, and leases each after the one before is acked",
|
|
838
|
+
run: async (ctx) => {
|
|
839
|
+
const registered = await registerSync(ctx);
|
|
840
|
+
if ("skip" in registered) return registered.skip;
|
|
841
|
+
try {
|
|
842
|
+
const run = ctx.labels.conformance;
|
|
843
|
+
const author = {
|
|
844
|
+
id: `${SYNC_KEY}:user:${run}`,
|
|
845
|
+
handle: "conformance",
|
|
846
|
+
name: "Conformance",
|
|
847
|
+
avatar: null,
|
|
848
|
+
isBot: false
|
|
849
|
+
};
|
|
850
|
+
const thread = `${SYNC_KEY}:${run}:inbox`;
|
|
851
|
+
const eventOf = (message) => ({
|
|
852
|
+
id: `conformance-inbox-${run}-${message}`,
|
|
853
|
+
event: {
|
|
854
|
+
kind: "message",
|
|
855
|
+
channel: `${SYNC_KEY}:${run}`,
|
|
856
|
+
thread,
|
|
857
|
+
subthread: null,
|
|
858
|
+
message,
|
|
859
|
+
isRoot: message === "1",
|
|
860
|
+
text: `conformance: inbox event ${message}`,
|
|
861
|
+
context: null,
|
|
862
|
+
author
|
|
863
|
+
},
|
|
864
|
+
raw: {
|
|
865
|
+
conformance: run,
|
|
866
|
+
message
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
const pushed = [eventOf("1"), eventOf("2")];
|
|
870
|
+
await ctx.board.inboxPush(SYNC_KEY, pushed);
|
|
871
|
+
await ctx.board.inboxPush(SYNC_KEY, [pushed[0]]);
|
|
872
|
+
const lease = async () => (await ctx.board.inboxNext(SYNC_KEY, { limit: 50 })).filter((item) => item.thread === thread);
|
|
873
|
+
for (const { event, raw } of pushed) {
|
|
874
|
+
const leased = await lease();
|
|
875
|
+
same(leased.length, 1, `the events leased from the platform thread, for message ${event.message}`);
|
|
876
|
+
const item = leased[0];
|
|
877
|
+
same(item.event.kind === "message" ? item.event.message : null, event.message, "the platform message leased next");
|
|
878
|
+
same(JSON.stringify(item.raw), JSON.stringify(raw), "the leased event's raw payload");
|
|
879
|
+
await ctx.board.inboxAck(SYNC_KEY, item.id, { lease: item.lease });
|
|
880
|
+
}
|
|
881
|
+
same((await lease()).length, 0, "the events leased once both are acked");
|
|
882
|
+
} finally {
|
|
883
|
+
await ctx.board.register({
|
|
884
|
+
...registered.where,
|
|
885
|
+
watch: {}
|
|
886
|
+
}).catch(() => {});
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
},
|
|
890
|
+
{
|
|
891
|
+
group: "sync",
|
|
892
|
+
name: "hands a sync each message change as a match of its @sync watcher",
|
|
893
|
+
run: async (ctx) => {
|
|
894
|
+
const access = compileAccess(ctx.me.can);
|
|
895
|
+
if (access.anywhere("sync") && !access.can("sync", ctx.channel)) return `this token can't sync in ${ctx.channel}`;
|
|
896
|
+
const registered = await registerSync(ctx);
|
|
897
|
+
if ("skip" in registered) return registered.skip;
|
|
898
|
+
try {
|
|
899
|
+
const message = await post(ctx, "conformance: for a sync");
|
|
900
|
+
const deadline = Date.now() + 1e4;
|
|
901
|
+
let match;
|
|
902
|
+
while (!match && Date.now() < deadline) match = (await ctx.board.matches({ wait: 5 })).find((m) => m.watcher === "@sync" && m.event.message?.id === message.id);
|
|
903
|
+
check(match, "a pull should hand the sync a match for the post, as its @sync watcher's");
|
|
904
|
+
same(match.event.type, "message.posted", "the sync's match");
|
|
905
|
+
await ctx.board.ack(match.id, { lease: match.lease });
|
|
906
|
+
} finally {
|
|
907
|
+
await ctx.board.register({
|
|
908
|
+
...registered.where,
|
|
909
|
+
watch: {}
|
|
910
|
+
}).catch(() => {});
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
},
|
|
914
|
+
{
|
|
915
|
+
group: "sync",
|
|
916
|
+
name: "keeps noSync on a post and a reply, so sync sends neither",
|
|
917
|
+
run: async (ctx) => {
|
|
918
|
+
const { root, reply } = needRoot(ctx);
|
|
919
|
+
const thread = await ctx.board.thread(root.id);
|
|
920
|
+
same(thread.message.noSync, true, "a post's noSync, read back");
|
|
921
|
+
same(thread.replies.find((m) => m.id === reply.id)?.noSync, true, "a reply's noSync, read back");
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
];
|
|
925
|
+
/** Runs the checks against `board`, in order, and reports each. It never throws for what the board answers. */
|
|
926
|
+
async function conformance(board, options = {}) {
|
|
927
|
+
const checks = [];
|
|
928
|
+
const discovery = await board.discovery().catch((error) => error);
|
|
929
|
+
const wrong = discovery instanceof Error ? discovery.message : typeof discovery.audience !== "string" || typeof discovery.url !== "string" || !Array.isArray(discovery.sync) ? `discovery should have url, audience, and sync, got ${show(discovery)}` : null;
|
|
930
|
+
if (wrong !== null) {
|
|
931
|
+
checks.push({
|
|
932
|
+
group: "core",
|
|
933
|
+
name: "says in its discovery where it is",
|
|
934
|
+
status: "failed",
|
|
935
|
+
detail: wrong
|
|
936
|
+
});
|
|
937
|
+
return {
|
|
938
|
+
url: board.url,
|
|
939
|
+
checks,
|
|
940
|
+
ok: false
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
const url = discovery.url;
|
|
944
|
+
checks.push({
|
|
945
|
+
group: "core",
|
|
946
|
+
name: "says in its discovery where it is",
|
|
947
|
+
status: "passed"
|
|
948
|
+
});
|
|
949
|
+
const me = await board.me();
|
|
950
|
+
const channel = options.channel ?? "conformance";
|
|
951
|
+
const ctx = {
|
|
952
|
+
board,
|
|
953
|
+
channel,
|
|
954
|
+
labels: { conformance: crypto.randomUUID().slice(0, 8) },
|
|
955
|
+
start: (await board.messages({
|
|
956
|
+
channel,
|
|
957
|
+
limit: 1
|
|
958
|
+
})).cursor,
|
|
959
|
+
me,
|
|
960
|
+
posted: [],
|
|
961
|
+
...options.withToken ? { withToken: options.withToken } : {}
|
|
962
|
+
};
|
|
963
|
+
try {
|
|
964
|
+
for (const { group, name, run } of CHECKS) try {
|
|
965
|
+
const skipped = await run(ctx);
|
|
966
|
+
checks.push(skipped ? {
|
|
967
|
+
group,
|
|
968
|
+
name,
|
|
969
|
+
status: "skipped",
|
|
970
|
+
detail: skipped
|
|
971
|
+
} : {
|
|
972
|
+
group,
|
|
973
|
+
name,
|
|
974
|
+
status: "passed"
|
|
975
|
+
});
|
|
976
|
+
} catch (error) {
|
|
977
|
+
if (OPTIONAL.has(group) && error instanceof BoardError && error.status === 501) checks.push({
|
|
978
|
+
group,
|
|
979
|
+
name,
|
|
980
|
+
status: "skipped",
|
|
981
|
+
detail: `not kept here: ${error.message}`
|
|
982
|
+
});
|
|
983
|
+
else checks.push({
|
|
984
|
+
group,
|
|
985
|
+
name,
|
|
986
|
+
status: "failed",
|
|
987
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
} finally {
|
|
991
|
+
for (const id of [...ctx.posted].reverse()) await board.delete(id).catch(() => {});
|
|
992
|
+
}
|
|
993
|
+
return {
|
|
994
|
+
url,
|
|
995
|
+
checks,
|
|
996
|
+
ok: checks.every((c) => c.status !== "failed")
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
//#endregion
|
|
1000
|
+
export { conformance as t };
|