@skillcdn/console 0.4.1 → 0.4.2

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 (271) hide show
  1. package/README.md +19 -2
  2. package/SKILLCDN.md +7 -5
  3. package/dist/api.d.ts +4 -3
  4. package/dist/api.d.ts.map +1 -1
  5. package/dist/api.js +4 -3
  6. package/dist/api.js.map +1 -1
  7. package/dist/cli/cli.d.ts.map +1 -1
  8. package/dist/cli/cli.js +543 -34
  9. package/dist/cli/cli.js.map +1 -1
  10. package/dist/cli/format.d.ts +34 -3
  11. package/dist/cli/format.d.ts.map +1 -1
  12. package/dist/cli/format.js +277 -6
  13. package/dist/cli/format.js.map +1 -1
  14. package/dist/cli/help.d.ts.map +1 -1
  15. package/dist/cli/help.js +35 -2
  16. package/dist/cli/help.js.map +1 -1
  17. package/dist/cli/plugin-check.d.ts +21 -0
  18. package/dist/cli/plugin-check.d.ts.map +1 -0
  19. package/dist/cli/plugin-check.js +374 -0
  20. package/dist/cli/plugin-check.js.map +1 -0
  21. package/dist/cli/plugin-cli.d.ts +20 -0
  22. package/dist/cli/plugin-cli.d.ts.map +1 -0
  23. package/dist/cli/plugin-cli.js +123 -0
  24. package/dist/cli/plugin-cli.js.map +1 -0
  25. package/dist/cli/plugin-init.d.ts +20 -0
  26. package/dist/cli/plugin-init.d.ts.map +1 -0
  27. package/dist/cli/plugin-init.js +74 -0
  28. package/dist/cli/plugin-init.js.map +1 -0
  29. package/dist/cli/plugin-main.d.ts +3 -0
  30. package/dist/cli/plugin-main.d.ts.map +1 -0
  31. package/dist/cli/plugin-main.js +28 -0
  32. package/dist/cli/plugin-main.js.map +1 -0
  33. package/dist/client.d.ts +97 -1
  34. package/dist/client.d.ts.map +1 -1
  35. package/dist/client.js +63 -2
  36. package/dist/client.js.map +1 -1
  37. package/dist/compat.d.ts +1 -1
  38. package/dist/compat.js +2 -2
  39. package/dist/components/board.d.ts +8 -0
  40. package/dist/components/board.d.ts.map +1 -1
  41. package/dist/components/board.js +59 -15
  42. package/dist/components/board.js.map +1 -1
  43. package/dist/components/event-feed.d.ts +1 -1
  44. package/dist/components/event-feed.d.ts.map +1 -1
  45. package/dist/components/event-feed.js +33 -1
  46. package/dist/components/event-feed.js.map +1 -1
  47. package/dist/components/plugin-frame.d.ts +52 -0
  48. package/dist/components/plugin-frame.d.ts.map +1 -0
  49. package/dist/components/plugin-frame.js +314 -0
  50. package/dist/components/plugin-frame.js.map +1 -0
  51. package/dist/components/plugin-settings.d.ts +38 -0
  52. package/dist/components/plugin-settings.d.ts.map +1 -0
  53. package/dist/components/plugin-settings.js +348 -0
  54. package/dist/components/plugin-settings.js.map +1 -0
  55. package/dist/components/shell.d.ts +2 -0
  56. package/dist/components/shell.d.ts.map +1 -1
  57. package/dist/components/shell.js +26 -1
  58. package/dist/components/shell.js.map +1 -1
  59. package/dist/components/workspace.d.ts +7 -1
  60. package/dist/components/workspace.d.ts.map +1 -1
  61. package/dist/components/workspace.js +1 -1
  62. package/dist/components/workspace.js.map +1 -1
  63. package/dist/console.css +327 -0
  64. package/dist/console.d.ts.map +1 -1
  65. package/dist/console.js +136 -26
  66. package/dist/console.js.map +1 -1
  67. package/dist/data.d.ts +7 -1
  68. package/dist/data.d.ts.map +1 -1
  69. package/dist/data.js +19 -2
  70. package/dist/data.js.map +1 -1
  71. package/dist/i18n/en.d.ts +149 -0
  72. package/dist/i18n/en.d.ts.map +1 -1
  73. package/dist/i18n/en.js +160 -0
  74. package/dist/i18n/en.js.map +1 -1
  75. package/dist/i18n/ko.d.ts.map +1 -1
  76. package/dist/i18n/ko.js +144 -0
  77. package/dist/i18n/ko.js.map +1 -1
  78. package/dist/index.d.ts +3 -1
  79. package/dist/index.d.ts.map +1 -1
  80. package/dist/index.js +3 -1
  81. package/dist/index.js.map +1 -1
  82. package/dist/plugin/args.d.ts +148 -0
  83. package/dist/plugin/args.d.ts.map +1 -0
  84. package/dist/plugin/args.js +369 -0
  85. package/dist/plugin/args.js.map +1 -0
  86. package/dist/plugin/bridge.d.ts +146 -0
  87. package/dist/plugin/bridge.d.ts.map +1 -0
  88. package/dist/plugin/bridge.js +125 -0
  89. package/dist/plugin/bridge.js.map +1 -0
  90. package/dist/plugin/capabilities.d.ts +20 -0
  91. package/dist/plugin/capabilities.d.ts.map +1 -0
  92. package/dist/plugin/capabilities.js +133 -0
  93. package/dist/plugin/capabilities.js.map +1 -0
  94. package/dist/plugin/context.d.ts +374 -0
  95. package/dist/plugin/context.d.ts.map +1 -0
  96. package/dist/plugin/context.js +2 -0
  97. package/dist/plugin/context.js.map +1 -0
  98. package/dist/plugin/define.d.ts +208 -0
  99. package/dist/plugin/define.d.ts.map +1 -0
  100. package/dist/plugin/define.js +482 -0
  101. package/dist/plugin/define.js.map +1 -0
  102. package/dist/plugin/definition-error.d.ts +11 -0
  103. package/dist/plugin/definition-error.d.ts.map +1 -0
  104. package/dist/plugin/definition-error.js +17 -0
  105. package/dist/plugin/definition-error.js.map +1 -0
  106. package/dist/plugin/limits.d.ts +67 -0
  107. package/dist/plugin/limits.d.ts.map +1 -0
  108. package/dist/plugin/limits.js +70 -0
  109. package/dist/plugin/limits.js.map +1 -0
  110. package/dist/plugin/load-worker.d.ts +7 -0
  111. package/dist/plugin/load-worker.d.ts.map +1 -0
  112. package/dist/plugin/load-worker.js +41 -0
  113. package/dist/plugin/load-worker.js.map +1 -0
  114. package/dist/plugin/load.d.ts +18 -0
  115. package/dist/plugin/load.d.ts.map +1 -0
  116. package/dist/plugin/load.js +62 -0
  117. package/dist/plugin/load.js.map +1 -0
  118. package/dist/plugin/manifest.d.ts +237 -0
  119. package/dist/plugin/manifest.d.ts.map +1 -0
  120. package/dist/plugin/manifest.js +411 -0
  121. package/dist/plugin/manifest.js.map +1 -0
  122. package/dist/plugin/refusals.d.ts +49 -0
  123. package/dist/plugin/refusals.d.ts.map +1 -0
  124. package/dist/plugin/refusals.js +79 -0
  125. package/dist/plugin/refusals.js.map +1 -0
  126. package/dist/plugin/rest-schemas.d.ts +556 -0
  127. package/dist/plugin/rest-schemas.d.ts.map +1 -0
  128. package/dist/plugin/rest-schemas.js +262 -0
  129. package/dist/plugin/rest-schemas.js.map +1 -0
  130. package/dist/plugin/sandbox.d.ts +23 -0
  131. package/dist/plugin/sandbox.d.ts.map +1 -0
  132. package/dist/plugin/sandbox.js +99 -0
  133. package/dist/plugin/sandbox.js.map +1 -0
  134. package/dist/plugin/testing.d.ts +45 -0
  135. package/dist/plugin/testing.d.ts.map +1 -0
  136. package/dist/plugin/testing.js +420 -0
  137. package/dist/plugin/testing.js.map +1 -0
  138. package/dist/plugin/web-react.d.ts +11 -0
  139. package/dist/plugin/web-react.d.ts.map +1 -0
  140. package/dist/plugin/web-react.js +34 -0
  141. package/dist/plugin/web-react.js.map +1 -0
  142. package/dist/plugin/web.d.ts +95 -0
  143. package/dist/plugin/web.d.ts.map +1 -0
  144. package/dist/plugin/web.js +245 -0
  145. package/dist/plugin/web.js.map +1 -0
  146. package/dist/plugin-nav.d.ts +29 -0
  147. package/dist/plugin-nav.d.ts.map +1 -0
  148. package/dist/plugin-nav.js +92 -0
  149. package/dist/plugin-nav.js.map +1 -0
  150. package/dist/plugin-testing.d.ts +2 -0
  151. package/dist/plugin-testing.d.ts.map +1 -0
  152. package/dist/plugin-testing.js +4 -0
  153. package/dist/plugin-testing.js.map +1 -0
  154. package/dist/plugin-web-react.d.ts +3 -0
  155. package/dist/plugin-web-react.d.ts.map +1 -0
  156. package/dist/plugin-web-react.js +3 -0
  157. package/dist/plugin-web-react.js.map +1 -0
  158. package/dist/plugin-web.d.ts +4 -0
  159. package/dist/plugin-web.d.ts.map +1 -0
  160. package/dist/plugin-web.js +5 -0
  161. package/dist/plugin-web.js.map +1 -0
  162. package/dist/plugin.d.ts +11 -0
  163. package/dist/plugin.d.ts.map +1 -0
  164. package/dist/plugin.js +15 -0
  165. package/dist/plugin.js.map +1 -0
  166. package/dist/router.d.ts +28 -0
  167. package/dist/router.d.ts.map +1 -1
  168. package/dist/router.js +47 -0
  169. package/dist/router.js.map +1 -1
  170. package/dist/routes.d.ts +4 -0
  171. package/dist/routes.d.ts.map +1 -1
  172. package/dist/routes.js +6 -0
  173. package/dist/routes.js.map +1 -1
  174. package/dist/schemas.d.ts +117 -0
  175. package/dist/schemas.d.ts.map +1 -1
  176. package/dist/schemas.js +40 -1
  177. package/dist/schemas.js.map +1 -1
  178. package/dist/tokens.d.ts +2 -0
  179. package/dist/tokens.d.ts.map +1 -0
  180. package/dist/tokens.js +101 -0
  181. package/dist/tokens.js.map +1 -0
  182. package/dist/vocabulary.d.ts +11 -1
  183. package/dist/vocabulary.d.ts.map +1 -1
  184. package/dist/vocabulary.js +24 -0
  185. package/dist/vocabulary.js.map +1 -1
  186. package/dist/web.d.ts.map +1 -1
  187. package/dist/web.js +2 -0
  188. package/dist/web.js.map +1 -1
  189. package/docs/specs/cli.md +11 -4
  190. package/docs/specs/plugins.md +487 -0
  191. package/docs/specs/rest.md +3 -2
  192. package/package.json +20 -2
  193. package/skills/building-a-console/SKILL.md +25 -26
  194. package/skills/building-a-console/references/configuring.md +40 -0
  195. package/skills/building-a-console/references/the-default-console.md +1 -1
  196. package/skills/building-a-console/references/the-look-from-a-website.md +5 -5
  197. package/skills/building-a-plugin/SKILL.md +137 -0
  198. package/skills/deploying-a-console/SKILL.md +24 -26
  199. package/skills/deploying-a-console/references/access.md +13 -9
  200. package/skills/deploying-a-console/references/resources.md +25 -10
  201. package/skills/deploying-a-console/references/running-it-again.md +15 -0
  202. package/skills/deploying-a-console/references/sizing.md +2 -0
  203. package/skills/planning-the-board/SKILL.md +96 -0
  204. package/skills/planning-the-board/references/a-task.md +36 -0
  205. package/skills/planning-the-board/references/setting-up.md +44 -0
  206. package/skills/working-the-board/SKILL.md +43 -34
  207. package/skills/working-the-board/references/the-pack.md +19 -0
  208. package/src/api.ts +8 -0
  209. package/src/cli/cli.ts +697 -33
  210. package/src/cli/format.ts +356 -9
  211. package/src/cli/help.ts +41 -2
  212. package/src/cli/plugin-check.ts +470 -0
  213. package/src/cli/plugin-cli.ts +148 -0
  214. package/src/cli/plugin-init.ts +90 -0
  215. package/src/cli/plugin-main.ts +30 -0
  216. package/src/client.ts +294 -0
  217. package/src/compat.ts +2 -2
  218. package/src/components/board.tsx +62 -14
  219. package/src/components/event-feed.tsx +42 -1
  220. package/src/components/plugin-frame.tsx +437 -0
  221. package/src/components/plugin-settings.tsx +1111 -0
  222. package/src/components/shell.tsx +82 -14
  223. package/src/components/workspace.tsx +18 -0
  224. package/src/console.tsx +286 -19
  225. package/src/data.ts +27 -2
  226. package/src/i18n/en.ts +168 -0
  227. package/src/i18n/ko.ts +148 -0
  228. package/src/index.ts +12 -0
  229. package/src/plugin/args.ts +526 -0
  230. package/src/plugin/bridge.ts +314 -0
  231. package/src/plugin/capabilities.ts +150 -0
  232. package/src/plugin/context.ts +416 -0
  233. package/src/plugin/define.ts +792 -0
  234. package/src/plugin/definition-error.ts +19 -0
  235. package/src/plugin/limits.ts +70 -0
  236. package/src/plugin/load-worker.ts +50 -0
  237. package/src/plugin/load.ts +82 -0
  238. package/src/plugin/manifest.ts +561 -0
  239. package/src/plugin/refusals.ts +94 -0
  240. package/src/plugin/rest-schemas.ts +354 -0
  241. package/src/plugin/sandbox.ts +123 -0
  242. package/src/plugin/testing.ts +612 -0
  243. package/src/plugin/web-react.ts +45 -0
  244. package/src/plugin/web.ts +403 -0
  245. package/src/plugin-nav.ts +132 -0
  246. package/src/plugin-testing.ts +10 -0
  247. package/src/plugin-web-react.ts +4 -0
  248. package/src/plugin-web.ts +35 -0
  249. package/src/plugin.ts +126 -0
  250. package/src/router.ts +73 -0
  251. package/src/routes.ts +7 -0
  252. package/src/schemas.ts +43 -0
  253. package/src/styles/console.css +327 -0
  254. package/src/tokens.ts +101 -0
  255. package/src/vocabulary.ts +30 -0
  256. package/src/web.ts +2 -0
  257. package/template/plugin/.gitignore +2 -0
  258. package/template/plugin/README.md +26 -0
  259. package/template/plugin/SKILLCDN.md +13 -0
  260. package/template/plugin/build.mjs +40 -0
  261. package/template/plugin/console-plugin.json +50 -0
  262. package/template/plugin/package.json +27 -0
  263. package/template/plugin/skills/__key__/SKILL.md +23 -0
  264. package/template/plugin/src/check.test.ts +34 -0
  265. package/template/plugin/src/server/index.test.ts +41 -0
  266. package/template/plugin/src/server/index.ts +72 -0
  267. package/template/plugin/src/web/index.css +51 -0
  268. package/template/plugin/src/web/index.ts +115 -0
  269. package/template/plugin/tsconfig.json +17 -0
  270. package/skills/working-the-board/references/setting-up.md +0 -3
  271. /package/skills/{working-the-board → planning-the-board}/references/project-rules.md +0 -0
@@ -0,0 +1,792 @@
1
+ // The server module of a plugin (docs/specs/plugins.md, "The server module"): `definePlugin` and
2
+ // the definers of its parts. Every definer yields a plain object the host can describe to the
3
+ // console beside the functions it runs: the console parses command lines, writes help and draws
4
+ // forms from the description, never from the code. What is not a definition the console can hold
5
+ // is refused here, when the module loads, with every problem named.
6
+ import type { ArgDefinitions, ArgDescription, ArgsOf } from "./args.js";
7
+ import type {
8
+ CollectionDefinition,
9
+ EndpointRequest,
10
+ EndpointResponse,
11
+ PluginContext,
12
+ PluginHookEvent,
13
+ RecordSchema,
14
+ } from "./context.js";
15
+ import { PluginDefinitionError } from "./definition-error.js";
16
+ import {
17
+ MAX_PLUGIN_ABOUT_LENGTH,
18
+ MAX_PLUGIN_COLLECTIONS,
19
+ MAX_PLUGIN_ENDPOINTS,
20
+ MAX_PLUGIN_INDEXES,
21
+ MAX_PLUGIN_OPERATIONS,
22
+ MIN_PLUGIN_JOB_INTERVAL_MS,
23
+ } from "./limits.js";
24
+ import {
25
+ CURRENT_PLUGIN_API,
26
+ PLUGIN_KEY_PATTERN,
27
+ RESERVED_ARG_NAMES,
28
+ RESERVED_COMMAND_NAMES,
29
+ } from "./manifest.js";
30
+
31
+ const FIELD = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
32
+ const HOOK_KIND =
33
+ /^(?:console:[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*|plugin:[a-z][a-z0-9-]{1,31}:[a-z][a-z0-9-]{1,31})$/;
34
+ const TIME_OF_DAY = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
35
+ const DURATION = /^P(?!$)(?:(\d+)W)?(?:(\d+)D)?(?:T(?!$)(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
36
+ const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
37
+ const HEADER_NAME = /^[a-z0-9-]{1,64}$/;
38
+ const DAY_MS = 24 * 60 * 60 * 1000;
39
+
40
+ /** The milliseconds an ISO 8601 duration such as `P1D`, `PT30M` or `P1W` stands for; nothing for what is not one. */
41
+ export function durationMs(iso: string): number | undefined {
42
+ const match = DURATION.exec(iso);
43
+ if (match === null) {
44
+ return undefined;
45
+ }
46
+ const [, weeks, days, hours, minutes, seconds] = match;
47
+ return (
48
+ Number(weeks ?? 0) * 7 * DAY_MS +
49
+ Number(days ?? 0) * DAY_MS +
50
+ Number(hours ?? 0) * 60 * 60 * 1000 +
51
+ Number(minutes ?? 0) * 60 * 1000 +
52
+ Number(seconds ?? 0) * 1000
53
+ );
54
+ }
55
+
56
+ function isKey(value: unknown): value is string {
57
+ return typeof value === "string" && PLUGIN_KEY_PATTERN.test(value);
58
+ }
59
+
60
+ function aboutProblem(what: string, about: unknown): string | undefined {
61
+ if (
62
+ typeof about !== "string" ||
63
+ about.length === 0 ||
64
+ about.length > MAX_PLUGIN_ABOUT_LENGTH ||
65
+ /[\r\n]/.test(about)
66
+ ) {
67
+ return `${what} needs about, one line of 1 to ${MAX_PLUGIN_ABOUT_LENGTH} characters`;
68
+ }
69
+ return undefined;
70
+ }
71
+
72
+ function rolesProblem(what: string, roles: unknown): string | undefined {
73
+ if (roles === undefined) {
74
+ return undefined;
75
+ }
76
+ if (!Array.isArray(roles) || roles.length === 0 || !roles.every(isKey)) {
77
+ return `${what} names roles that are not keys, or none at all; leave roles out for every member`;
78
+ }
79
+ return undefined;
80
+ }
81
+
82
+ function fail(problems: readonly (string | undefined)[]): void {
83
+ const found = problems.filter((problem): problem is string => problem !== undefined);
84
+ if (found.length > 0) {
85
+ throw new PluginDefinitionError(found);
86
+ }
87
+ }
88
+
89
+ export interface CollectionInput<S extends RecordSchema> {
90
+ /** A zod schema of the record's data, or any schema that follows Standard Schema. Checked on every write. */
91
+ readonly schema: S;
92
+ /** The fields `where` and `order` may name, and `aggregate` groups by; up to eight. */
93
+ readonly indexes?: readonly string[];
94
+ /** The fields searched as words. */
95
+ readonly search?: readonly string[];
96
+ /** The field that holds a person's id, by which `readOwn` roles read their own records. */
97
+ readonly owner?: string;
98
+ /** Whether a record is linked to a project: never, where the caller says, or always. */
99
+ readonly project?: "none" | "optional" | "required";
100
+ /** The roles that read the collection through the console's routes and the bridge. */
101
+ readonly read?: readonly string[];
102
+ /** The roles that read their own records in it. */
103
+ readonly readOwn?: readonly string[];
104
+ /** A version kept per write; on unless said otherwise. */
105
+ readonly history?: boolean;
106
+ }
107
+
108
+ /** A collection of records the console keeps for the plugin, by name. */
109
+ export function defineCollection<S extends RecordSchema>(
110
+ name: string,
111
+ input: CollectionInput<S>,
112
+ ): CollectionDefinition<S> {
113
+ const problems: (string | undefined)[] = [];
114
+ if (!isKey(name)) {
115
+ problems.push(`the collection "${String(name)}" is not named as a key`);
116
+ }
117
+ if (
118
+ typeof input?.schema !== "object" ||
119
+ input.schema === null ||
120
+ !("~standard" in input.schema)
121
+ ) {
122
+ problems.push(`the collection ${name} has no schema, or one that is not a zod schema`);
123
+ }
124
+ const indexes = input.indexes ?? [];
125
+ if (indexes.length > MAX_PLUGIN_INDEXES) {
126
+ problems.push(
127
+ `the collection ${name} indexes ${indexes.length} fields; ${MAX_PLUGIN_INDEXES} at most`,
128
+ );
129
+ }
130
+ for (const field of [
131
+ ...indexes,
132
+ ...(input.search ?? []),
133
+ ...(input.owner === undefined ? [] : [input.owner]),
134
+ ]) {
135
+ if (!FIELD.test(field)) {
136
+ problems.push(`the collection ${name} names the field "${field}", which is not a field name`);
137
+ }
138
+ }
139
+ if (new Set(indexes).size !== indexes.length) {
140
+ problems.push(`the collection ${name} indexes a field twice`);
141
+ }
142
+ if (input.owner !== undefined && !indexes.includes(input.owner)) {
143
+ problems.push(
144
+ `the collection ${name} has the owner field ${input.owner}, which must be indexed`,
145
+ );
146
+ }
147
+ if (input.project !== undefined && !["none", "optional", "required"].includes(input.project)) {
148
+ problems.push(
149
+ `the collection ${name} has project "${String(input.project)}"; none, optional or required`,
150
+ );
151
+ }
152
+ problems.push(rolesProblem(`the collection ${name}'s read`, input.read));
153
+ problems.push(rolesProblem(`the collection ${name}'s readOwn`, input.readOwn));
154
+ if (input.readOwn !== undefined && input.owner === undefined) {
155
+ problems.push(`the collection ${name} has readOwn roles and no owner field`);
156
+ }
157
+ fail(problems);
158
+ return Object.freeze({
159
+ name,
160
+ schema: input.schema,
161
+ indexes: Object.freeze([...indexes]),
162
+ search: Object.freeze([...(input.search ?? [])]),
163
+ owner: input.owner ?? null,
164
+ project: input.project ?? "none",
165
+ read: Object.freeze([...(input.read ?? [])]),
166
+ readOwn: Object.freeze([...(input.readOwn ?? [])]),
167
+ history: input.history ?? true,
168
+ });
169
+ }
170
+
171
+ /** What a command answers: what it prints, what `--json` prints, and the decision the caller waits for, if any. */
172
+ export interface CommandResult {
173
+ readonly text: string;
174
+ readonly data?: unknown;
175
+ readonly waiting?: string;
176
+ }
177
+
178
+ export interface CommandInput<A extends ArgDefinitions> {
179
+ readonly about: string;
180
+ readonly args?: A;
181
+ /** The roles that may run it, any of them; left out, every member may. */
182
+ readonly roles?: readonly string[];
183
+ run(ctx: PluginContext, args: ArgsOf<A>): Promise<CommandResult>;
184
+ }
185
+
186
+ export interface CommandDefinition<A extends ArgDefinitions = ArgDefinitions> {
187
+ readonly kind: "command";
188
+ readonly about: string;
189
+ readonly args: A;
190
+ readonly roles: readonly string[] | null;
191
+ run(ctx: PluginContext, args: ArgsOf<A>): Promise<CommandResult>;
192
+ }
193
+
194
+ /** A file a query answers with, in place of JSON. */
195
+ export interface QueryFile {
196
+ readonly file: { readonly name: string; readonly type: string; readonly bytes: Uint8Array };
197
+ }
198
+
199
+ export function isQueryFile(value: unknown): value is QueryFile {
200
+ if (typeof value !== "object" || value === null || !("file" in value)) {
201
+ return false;
202
+ }
203
+ const file = (value as { file: unknown }).file;
204
+ return (
205
+ typeof file === "object" &&
206
+ file !== null &&
207
+ typeof (file as { name?: unknown }).name === "string" &&
208
+ typeof (file as { type?: unknown }).type === "string" &&
209
+ (file as { bytes?: unknown }).bytes instanceof Uint8Array
210
+ );
211
+ }
212
+
213
+ export interface QueryInput<A extends ArgDefinitions> {
214
+ readonly about: string;
215
+ readonly args?: A;
216
+ readonly roles?: readonly string[];
217
+ /** JSON, or a `QueryFile` for an export. */
218
+ run(ctx: PluginContext, args: ArgsOf<A>): Promise<unknown>;
219
+ }
220
+
221
+ export interface QueryDefinition<A extends ArgDefinitions = ArgDefinitions> {
222
+ readonly kind: "query";
223
+ readonly about: string;
224
+ readonly args: A;
225
+ readonly roles: readonly string[] | null;
226
+ run(ctx: PluginContext, args: ArgsOf<A>): Promise<unknown>;
227
+ }
228
+
229
+ function argsProblems(what: string, args: unknown): string[] {
230
+ if (args === undefined) {
231
+ return [];
232
+ }
233
+ if (typeof args !== "object" || args === null || Array.isArray(args)) {
234
+ return [`${what} has args that are not named definers`];
235
+ }
236
+ const problems: string[] = [];
237
+ let positionalOptional = false;
238
+ for (const [name, definition] of Object.entries(args as Record<string, ArgDescription>)) {
239
+ if (!isKey(name)) {
240
+ problems.push(`${what} has the argument "${name}", which is not named as a key`);
241
+ } else if (RESERVED_ARG_NAMES.includes(name)) {
242
+ problems.push(
243
+ `${what} has the argument "${name}", a flag the command keeps for itself (${RESERVED_ARG_NAMES.join(", ")})`,
244
+ );
245
+ }
246
+ if (
247
+ typeof definition !== "object" ||
248
+ definition === null ||
249
+ typeof definition.type !== "string"
250
+ ) {
251
+ problems.push(`${what}'s argument ${name} is not from arg.*`);
252
+ continue;
253
+ }
254
+ if (definition.positional) {
255
+ if (positionalOptional && !definition.optional) {
256
+ problems.push(
257
+ `${what}'s argument ${name} is positional and required after an optional one`,
258
+ );
259
+ }
260
+ positionalOptional = positionalOptional || definition.optional;
261
+ }
262
+ }
263
+ return problems;
264
+ }
265
+
266
+ function operation<A extends ArgDefinitions>(
267
+ kind: "command" | "query",
268
+ input: CommandInput<A> | QueryInput<A>,
269
+ ): { about: string; args: A; roles: readonly string[] | null } {
270
+ const what = `a ${kind}`;
271
+ fail([
272
+ aboutProblem(what, input?.about),
273
+ ...argsProblems(what, input?.args),
274
+ rolesProblem(what, input?.roles),
275
+ typeof input?.run === "function" ? undefined : `${what} needs run, an async function`,
276
+ ]);
277
+ return {
278
+ about: input.about,
279
+ args: Object.freeze({ ...(input.args ?? ({} as A)) }),
280
+ roles: input.roles === undefined ? null : Object.freeze([...input.roles]),
281
+ };
282
+ }
283
+
284
+ /** Plugin code that writes, run as the person who asked. */
285
+ export function defineCommand<A extends ArgDefinitions = Record<never, never>>(
286
+ input: CommandInput<A>,
287
+ ): CommandDefinition<A> {
288
+ const common = operation("command", input);
289
+ return Object.freeze({ kind: "command", ...common, run: input.run });
290
+ }
291
+
292
+ /** Plugin code that reads, run as the person who asked. */
293
+ export function defineQuery<A extends ArgDefinitions = Record<never, never>>(
294
+ input: QueryInput<A>,
295
+ ): QueryDefinition<A> {
296
+ const common = operation("query", input);
297
+ return Object.freeze({ kind: "query", ...common, run: input.run });
298
+ }
299
+
300
+ export type EndpointAuth = "person" | "webhook";
301
+
302
+ export interface EndpointInput {
303
+ /** `person`: a session or a token, with `roles`; `webhook`: nobody, the plugin verifies the signature with `secret`. */
304
+ readonly auth: EndpointAuth;
305
+ readonly roles?: readonly string[];
306
+ /** The `secret` setting a webhook's signature is verified with, through `ctx.secrets.verify`. */
307
+ readonly secret?: string;
308
+ /** The methods accepted; `POST` alone when left out. */
309
+ readonly methods?: readonly string[];
310
+ /** The request headers handed to `run` beside `content-type`, in lowercase. */
311
+ readonly headers?: readonly string[];
312
+ run(ctx: PluginContext, request: EndpointRequest): Promise<EndpointResponse>;
313
+ }
314
+
315
+ export interface EndpointDefinition {
316
+ readonly kind: "endpoint";
317
+ readonly auth: EndpointAuth;
318
+ readonly roles: readonly string[] | null;
319
+ readonly secret: string | null;
320
+ readonly methods: readonly string[];
321
+ readonly headers: readonly string[];
322
+ run(ctx: PluginContext, request: EndpointRequest): Promise<EndpointResponse>;
323
+ }
324
+
325
+ /** An inbound request the plugin accepts under `/api/v1/plugins/<key>/x/<name>`, with the `endpoints` capability. */
326
+ export function defineEndpoint(input: EndpointInput): EndpointDefinition {
327
+ const problems: (string | undefined)[] = [];
328
+ if (input?.auth !== "person" && input?.auth !== "webhook") {
329
+ problems.push("an endpoint's auth is person or webhook");
330
+ }
331
+ problems.push(rolesProblem("an endpoint", input?.roles));
332
+ if (input?.auth === "webhook" && input.roles !== undefined) {
333
+ problems.push("a webhook endpoint names no roles: nobody is signed in");
334
+ }
335
+ if (input?.secret !== undefined && (!isKey(input.secret) || input.auth !== "webhook")) {
336
+ problems.push("an endpoint's secret names a secret setting, for a webhook alone");
337
+ }
338
+ const methods = input?.methods ?? ["POST"];
339
+ if (
340
+ methods.length === 0 ||
341
+ !methods.every((method) => (HTTP_METHODS as readonly string[]).includes(method))
342
+ ) {
343
+ problems.push(`an endpoint's methods are among ${HTTP_METHODS.join(", ")}`);
344
+ }
345
+ const headers = input?.headers ?? [];
346
+ if (headers.length > 10 || !headers.every((header) => HEADER_NAME.test(header))) {
347
+ problems.push("an endpoint names up to ten request headers, in lowercase");
348
+ }
349
+ if (typeof input?.run !== "function") {
350
+ problems.push("an endpoint needs run, an async function");
351
+ }
352
+ fail(problems);
353
+ return Object.freeze({
354
+ kind: "endpoint",
355
+ auth: input.auth,
356
+ roles: input.roles === undefined ? null : Object.freeze([...input.roles]),
357
+ secret: input.secret ?? null,
358
+ methods: Object.freeze([...methods]),
359
+ headers: Object.freeze([...headers]),
360
+ run: input.run,
361
+ });
362
+ }
363
+
364
+ export type HookHandler = (ctx: PluginContext, event: PluginHookEvent) => Promise<void>;
365
+
366
+ export interface JobDefinition {
367
+ readonly about: string;
368
+ /** An ISO 8601 duration, five minutes or more. */
369
+ readonly every: string;
370
+ /** A time of day, `HH:MM` in the workspace's time zone, for a daily or longer `every`. */
371
+ readonly at?: string;
372
+ run(ctx: PluginContext): Promise<void>;
373
+ }
374
+
375
+ export interface UpgradeDefinition {
376
+ /** The `dataVersion` this upgrade brings the records to. */
377
+ readonly to: number;
378
+ readonly about: string;
379
+ run(ctx: PluginContext): Promise<void>;
380
+ }
381
+
382
+ export interface ApiDefinition<S extends RecordSchema = RecordSchema> {
383
+ readonly about: string;
384
+ /** The schema of what a caller sends. */
385
+ readonly input: S;
386
+ run(ctx: PluginContext, input: unknown): Promise<unknown>;
387
+ }
388
+
389
+ export type Lifecycle = (ctx: PluginContext) => Promise<void>;
390
+
391
+ export interface PluginParts {
392
+ readonly collections?: readonly CollectionDefinition[];
393
+ readonly commands?: Readonly<Record<string, CommandDefinition>>;
394
+ readonly queries?: Readonly<Record<string, QueryDefinition>>;
395
+ readonly endpoints?: Readonly<Record<string, EndpointDefinition>>;
396
+ /** Hooks: `"console:<kind>"` with `events:console`, `"plugin:<key>:<kind>"` with `events:plugins`. */
397
+ readonly on?: Readonly<Record<string, HookHandler>>;
398
+ readonly jobs?: Readonly<Record<string, JobDefinition>>;
399
+ readonly upgrades?: readonly UpgradeDefinition[];
400
+ /** What other plugins may call with `plugins:<key>`. */
401
+ readonly api?: Readonly<Record<string, ApiDefinition>>;
402
+ readonly onEnable?: Lifecycle;
403
+ readonly onDisable?: Lifecycle;
404
+ }
405
+
406
+ /** The default export of a plugin's server module. */
407
+ export interface PluginDefinition {
408
+ readonly pluginApi: number;
409
+ readonly collections: readonly CollectionDefinition[];
410
+ readonly commands: Readonly<Record<string, CommandDefinition>>;
411
+ readonly queries: Readonly<Record<string, QueryDefinition>>;
412
+ readonly endpoints: Readonly<Record<string, EndpointDefinition>>;
413
+ readonly on: Readonly<Record<string, HookHandler>>;
414
+ readonly jobs: Readonly<Record<string, JobDefinition>>;
415
+ readonly upgrades: readonly UpgradeDefinition[];
416
+ readonly api: Readonly<Record<string, ApiDefinition>>;
417
+ readonly onEnable: Lifecycle | null;
418
+ readonly onDisable: Lifecycle | null;
419
+ }
420
+
421
+ function namedProblems(
422
+ what: string,
423
+ record: Readonly<Record<string, unknown>> | undefined,
424
+ kind?: string,
425
+ ): string[] {
426
+ const problems: string[] = [];
427
+ for (const [name, value] of Object.entries(record ?? {})) {
428
+ if (!isKey(name)) {
429
+ problems.push(`the ${what} "${name}" is not named as a key`);
430
+ }
431
+ if (
432
+ kind !== undefined &&
433
+ (typeof value !== "object" || value === null || (value as { kind?: unknown }).kind !== kind)
434
+ ) {
435
+ problems.push(
436
+ `the ${what} ${name} is not from define${kind[0]?.toUpperCase()}${kind.slice(1)}`,
437
+ );
438
+ }
439
+ }
440
+ return problems;
441
+ }
442
+
443
+ /** The whole of a plugin's server module, checked once when it loads. */
444
+ export function definePlugin(parts: PluginParts): PluginDefinition {
445
+ const problems: (string | undefined)[] = [];
446
+ const collections = parts.collections ?? [];
447
+ if (!Array.isArray(collections)) {
448
+ problems.push("collections is a list of defineCollection");
449
+ } else {
450
+ if (collections.length > MAX_PLUGIN_COLLECTIONS) {
451
+ problems.push(`${collections.length} collections; ${MAX_PLUGIN_COLLECTIONS} at most`);
452
+ }
453
+ const names = collections.map((collection) => collection?.name);
454
+ if (new Set(names).size !== names.length) {
455
+ problems.push("two collections have one name");
456
+ }
457
+ for (const collection of collections) {
458
+ if (typeof collection?.name !== "string" || !("schema" in collection)) {
459
+ problems.push("a collection is not from defineCollection");
460
+ }
461
+ }
462
+ }
463
+ problems.push(...namedProblems("command", parts.commands, "command"));
464
+ problems.push(...namedProblems("query", parts.queries, "query"));
465
+ problems.push(...namedProblems("endpoint", parts.endpoints, "endpoint"));
466
+ problems.push(...namedProblems("job", parts.jobs));
467
+ problems.push(...namedProblems("api call", parts.api));
468
+ const operations = [...Object.keys(parts.commands ?? {}), ...Object.keys(parts.queries ?? {})];
469
+ if (operations.length > MAX_PLUGIN_OPERATIONS) {
470
+ problems.push(`${operations.length} commands and queries; ${MAX_PLUGIN_OPERATIONS} at most`);
471
+ }
472
+ if (new Set(operations).size !== operations.length) {
473
+ problems.push("a command and a query have one name");
474
+ }
475
+ for (const name of operations) {
476
+ if (RESERVED_COMMAND_NAMES.includes(name)) {
477
+ problems.push(
478
+ `${name} is a subcommand of console plugin <key>, and not a name for a command or a query`,
479
+ );
480
+ }
481
+ }
482
+ if (Object.keys(parts.endpoints ?? {}).length > MAX_PLUGIN_ENDPOINTS) {
483
+ problems.push(`more than ${MAX_PLUGIN_ENDPOINTS} endpoints`);
484
+ }
485
+ for (const [kind, handler] of Object.entries(parts.on ?? {})) {
486
+ if (!HOOK_KIND.test(kind)) {
487
+ problems.push(`the hook "${kind}" is neither console:<kind> nor plugin:<key>:<kind>`);
488
+ }
489
+ if (typeof handler !== "function") {
490
+ problems.push(`the hook ${kind} is not a function`);
491
+ }
492
+ }
493
+ for (const [name, job] of Object.entries(parts.jobs ?? {})) {
494
+ problems.push(aboutProblem(`the job ${name}`, job?.about));
495
+ const every = typeof job?.every === "string" ? durationMs(job.every) : undefined;
496
+ if (every === undefined || every < MIN_PLUGIN_JOB_INTERVAL_MS) {
497
+ problems.push(
498
+ `the job ${name} runs every "${String(job?.every)}", which is not an ISO 8601 duration of five minutes or more`,
499
+ );
500
+ } else if (job.at !== undefined && (!TIME_OF_DAY.test(job.at) || every < DAY_MS)) {
501
+ problems.push(
502
+ `the job ${name} has at, which is HH:MM for a job that runs daily or less often`,
503
+ );
504
+ }
505
+ if (typeof job?.run !== "function") {
506
+ problems.push(`the job ${name} needs run, an async function`);
507
+ }
508
+ }
509
+ const upgrades = parts.upgrades ?? [];
510
+ let last = 1;
511
+ for (const upgrade of upgrades) {
512
+ if (!Number.isInteger(upgrade?.to) || upgrade.to !== last + 1) {
513
+ problems.push(
514
+ `the upgrades go up one dataVersion at a time from 2: "${String(upgrade?.to)}" after ${last} does not`,
515
+ );
516
+ } else {
517
+ last = upgrade.to;
518
+ }
519
+ problems.push(aboutProblem(`the upgrade to ${String(upgrade?.to)}`, upgrade?.about));
520
+ if (typeof upgrade?.run !== "function") {
521
+ problems.push(`the upgrade to ${String(upgrade?.to)} needs run, an async function`);
522
+ }
523
+ }
524
+ for (const [name, call] of Object.entries(parts.api ?? {})) {
525
+ problems.push(aboutProblem(`the api call ${name}`, call?.about));
526
+ if (typeof call?.input !== "object" || call.input === null || !("~standard" in call.input)) {
527
+ problems.push(`the api call ${name} needs input, a zod schema`);
528
+ }
529
+ if (typeof call?.run !== "function") {
530
+ problems.push(`the api call ${name} needs run, an async function`);
531
+ }
532
+ }
533
+ for (const [name, hook] of [
534
+ ["onEnable", parts.onEnable],
535
+ ["onDisable", parts.onDisable],
536
+ ] as const) {
537
+ if (hook !== undefined && typeof hook !== "function") {
538
+ problems.push(`${name} is not a function`);
539
+ }
540
+ }
541
+ fail(problems);
542
+ return Object.freeze({
543
+ pluginApi: CURRENT_PLUGIN_API,
544
+ collections: Object.freeze([...collections]),
545
+ commands: Object.freeze({ ...(parts.commands ?? {}) }),
546
+ queries: Object.freeze({ ...(parts.queries ?? {}) }),
547
+ endpoints: Object.freeze({ ...(parts.endpoints ?? {}) }),
548
+ on: Object.freeze({ ...(parts.on ?? {}) }),
549
+ jobs: Object.freeze({ ...(parts.jobs ?? {}) }),
550
+ upgrades: Object.freeze([...upgrades]),
551
+ api: Object.freeze({ ...(parts.api ?? {}) }),
552
+ onEnable: parts.onEnable ?? null,
553
+ onDisable: parts.onDisable ?? null,
554
+ });
555
+ }
556
+
557
+ /** True for what `definePlugin` yields, whichever copy of this package built it. */
558
+ export function isPluginDefinition(value: unknown): value is PluginDefinition {
559
+ if (typeof value !== "object" || value === null) {
560
+ return false;
561
+ }
562
+ const candidate = value as Partial<PluginDefinition>;
563
+ return (
564
+ typeof candidate.pluginApi === "number" &&
565
+ Array.isArray(candidate.collections) &&
566
+ typeof candidate.commands === "object" &&
567
+ candidate.commands !== null &&
568
+ typeof candidate.queries === "object" &&
569
+ candidate.queries !== null &&
570
+ typeof candidate.endpoints === "object" &&
571
+ candidate.endpoints !== null &&
572
+ typeof candidate.on === "object" &&
573
+ candidate.on !== null &&
574
+ typeof candidate.jobs === "object" &&
575
+ candidate.jobs !== null &&
576
+ Array.isArray(candidate.upgrades) &&
577
+ typeof candidate.api === "object" &&
578
+ candidate.api !== null
579
+ );
580
+ }
581
+
582
+ /** A command or a query as the console describes it: enough to parse words, write help and draw a form. */
583
+ export interface OperationDescription {
584
+ readonly about: string;
585
+ readonly args: Readonly<Record<string, ArgDescription>>;
586
+ readonly roles: readonly string[] | null;
587
+ }
588
+
589
+ export interface CollectionDescription {
590
+ readonly name: string;
591
+ readonly indexes: readonly string[];
592
+ readonly search: readonly string[];
593
+ readonly owner: string | null;
594
+ readonly project: "none" | "optional" | "required";
595
+ readonly read: readonly string[];
596
+ readonly readOwn: readonly string[];
597
+ readonly history: boolean;
598
+ }
599
+
600
+ export interface EndpointDescription {
601
+ readonly auth: EndpointAuth;
602
+ readonly roles: readonly string[] | null;
603
+ readonly secret: string | null;
604
+ readonly methods: readonly string[];
605
+ readonly headers: readonly string[];
606
+ }
607
+
608
+ /** The server module as data: what crosses from the plugin's thread to the console, as JSON. */
609
+ export interface PluginDescription {
610
+ readonly pluginApi: number;
611
+ readonly collections: readonly CollectionDescription[];
612
+ readonly commands: Readonly<Record<string, OperationDescription>>;
613
+ readonly queries: Readonly<Record<string, OperationDescription>>;
614
+ readonly endpoints: Readonly<Record<string, EndpointDescription>>;
615
+ readonly hooks: readonly string[];
616
+ readonly jobs: Readonly<
617
+ Record<string, { readonly about: string; readonly every: string; readonly at: string | null }>
618
+ >;
619
+ readonly upgrades: readonly { readonly to: number; readonly about: string }[];
620
+ readonly api: Readonly<Record<string, { readonly about: string }>>;
621
+ readonly onEnable: boolean;
622
+ readonly onDisable: boolean;
623
+ }
624
+
625
+ function describeArg(description: ArgDescription): ArgDescription {
626
+ const copy: Record<string, unknown> = {
627
+ type: description.type,
628
+ optional: description.optional,
629
+ positional: description.positional,
630
+ };
631
+ for (const field of [
632
+ "about",
633
+ "secret",
634
+ "max",
635
+ "min",
636
+ "integer",
637
+ "options",
638
+ "collection",
639
+ ] as const) {
640
+ if (description[field] !== undefined) {
641
+ copy[field] = description[field];
642
+ }
643
+ }
644
+ if (description.of !== undefined) {
645
+ copy.of = describeArg(description.of);
646
+ }
647
+ return copy as unknown as ArgDescription;
648
+ }
649
+
650
+ function describeOperation(operation: CommandDefinition | QueryDefinition): OperationDescription {
651
+ return {
652
+ about: operation.about,
653
+ args: Object.fromEntries(
654
+ Object.entries(operation.args).map(([name, definition]) => [name, describeArg(definition)]),
655
+ ),
656
+ roles: operation.roles,
657
+ };
658
+ }
659
+
660
+ /** The definition as JSON, functions and schemas left behind. */
661
+ export function describePlugin(definition: PluginDefinition): PluginDescription {
662
+ return {
663
+ pluginApi: definition.pluginApi,
664
+ collections: definition.collections.map((collection) => ({
665
+ name: collection.name,
666
+ indexes: [...collection.indexes],
667
+ search: [...collection.search],
668
+ owner: collection.owner,
669
+ project: collection.project,
670
+ read: [...collection.read],
671
+ readOwn: [...collection.readOwn],
672
+ history: collection.history,
673
+ })),
674
+ commands: Object.fromEntries(
675
+ Object.entries(definition.commands).map(([name, command]) => [
676
+ name,
677
+ describeOperation(command),
678
+ ]),
679
+ ),
680
+ queries: Object.fromEntries(
681
+ Object.entries(definition.queries).map(([name, query]) => [name, describeOperation(query)]),
682
+ ),
683
+ endpoints: Object.fromEntries(
684
+ Object.entries(definition.endpoints).map(([name, endpoint]) => [
685
+ name,
686
+ {
687
+ auth: endpoint.auth,
688
+ roles: endpoint.roles,
689
+ secret: endpoint.secret,
690
+ methods: [...endpoint.methods],
691
+ headers: [...endpoint.headers],
692
+ },
693
+ ]),
694
+ ),
695
+ hooks: Object.keys(definition.on),
696
+ jobs: Object.fromEntries(
697
+ Object.entries(definition.jobs).map(([name, job]) => [
698
+ name,
699
+ { about: job.about, every: job.every, at: job.at ?? null },
700
+ ]),
701
+ ),
702
+ upgrades: definition.upgrades.map((upgrade) => ({ to: upgrade.to, about: upgrade.about })),
703
+ api: Object.fromEntries(
704
+ Object.entries(definition.api).map(([name, call]) => [name, { about: call.about }]),
705
+ ),
706
+ onEnable: definition.onEnable !== null,
707
+ onDisable: definition.onDisable !== null,
708
+ };
709
+ }
710
+
711
+ /** What a manifest must declare for a description to hold: the problems, or none. */
712
+ export function descriptionProblems(
713
+ description: PluginDescription,
714
+ manifest: {
715
+ readonly roles: Readonly<Record<string, unknown>>;
716
+ readonly capabilities: readonly string[];
717
+ readonly settings: readonly { readonly key: string; readonly type: string }[];
718
+ readonly dataVersion: number;
719
+ },
720
+ ): string[] {
721
+ const problems: string[] = [];
722
+ const roles = Object.keys(manifest.roles);
723
+ const collections = new Set(description.collections.map((collection) => collection.name));
724
+ const undeclared = (where: string, named: readonly string[] | null) => {
725
+ for (const role of named ?? []) {
726
+ if (!roles.includes(role)) {
727
+ problems.push(`${where} names the role ${role}, which the manifest does not declare`);
728
+ }
729
+ }
730
+ };
731
+ const needs = (where: string, capability: string) => {
732
+ if (!manifest.capabilities.includes(capability)) {
733
+ problems.push(
734
+ `${where} needs the capability ${capability}, which the manifest does not declare`,
735
+ );
736
+ }
737
+ };
738
+ for (const collection of description.collections) {
739
+ undeclared(`the collection ${collection.name}'s read`, collection.read);
740
+ undeclared(`the collection ${collection.name}'s readOwn`, collection.readOwn);
741
+ }
742
+ const checkArgs = (where: string, args: Readonly<Record<string, ArgDescription>>) => {
743
+ for (const [name, description] of Object.entries(args)) {
744
+ const inner = description.type === "list" ? description.of : description;
745
+ if (
746
+ inner?.type === "record" &&
747
+ inner.collection !== undefined &&
748
+ !collections.has(inner.collection)
749
+ ) {
750
+ problems.push(
751
+ `${where}'s argument ${name} names the collection ${inner.collection}, which is not defined`,
752
+ );
753
+ }
754
+ if (inner?.type === "file") {
755
+ needs(`${where}'s argument ${name}`, "files");
756
+ }
757
+ }
758
+ };
759
+ for (const [name, command] of Object.entries(description.commands)) {
760
+ undeclared(`the command ${name}`, command.roles);
761
+ checkArgs(`the command ${name}`, command.args);
762
+ }
763
+ for (const [name, query] of Object.entries(description.queries)) {
764
+ undeclared(`the query ${name}`, query.roles);
765
+ checkArgs(`the query ${name}`, query.args);
766
+ }
767
+ for (const [name, endpoint] of Object.entries(description.endpoints)) {
768
+ needs(`the endpoint ${name}`, "endpoints");
769
+ undeclared(`the endpoint ${name}`, endpoint.roles);
770
+ if (endpoint.secret !== null) {
771
+ const setting = manifest.settings.find((candidate) => candidate.key === endpoint.secret);
772
+ if (setting === undefined || setting.type !== "secret") {
773
+ problems.push(
774
+ `the endpoint ${name} verifies with the setting ${endpoint.secret}, which is not a secret setting of the manifest`,
775
+ );
776
+ }
777
+ }
778
+ }
779
+ for (const hook of description.hooks) {
780
+ needs(`the hook ${hook}`, hook.startsWith("console:") ? "events:console" : "events:plugins");
781
+ }
782
+ for (const name of Object.keys(description.jobs)) {
783
+ needs(`the job ${name}`, "jobs");
784
+ }
785
+ const reached = description.upgrades.at(-1)?.to ?? 1;
786
+ if (reached !== manifest.dataVersion) {
787
+ problems.push(
788
+ `the manifest's dataVersion is ${manifest.dataVersion} and the upgrades reach ${reached}: an upgrade brings the records to every version after 1`,
789
+ );
790
+ }
791
+ return problems;
792
+ }