@brydio/manifest 0.1.0-alpha.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/LICENSE +21 -0
- package/package.json +27 -0
- package/src/base.d.ts +73 -0
- package/src/base.js +61 -0
- package/src/bundle.d.ts +52 -0
- package/src/bundle.js +95 -0
- package/src/define.d.ts +49 -0
- package/src/define.js +21 -0
- package/src/document-limits.d.ts +21 -0
- package/src/document-limits.js +21 -0
- package/src/field-types.d.ts +157 -0
- package/src/field-types.js +298 -0
- package/src/grants.d.ts +20 -0
- package/src/grants.js +30 -0
- package/src/index.d.ts +17 -0
- package/src/index.js +17 -0
- package/src/migrations.d.ts +142 -0
- package/src/migrations.js +322 -0
- package/src/schema.d.ts +381 -0
- package/src/schema.js +375 -0
- package/src/sdk.d.ts +30 -0
- package/src/sdk.js +77 -0
- package/src/secrets.d.ts +32 -0
- package/src/secrets.js +81 -0
- package/src/tools.d.ts +21 -0
- package/src/tools.js +31 -0
- package/src/validate.d.ts +27 -0
- package/src/validate.js +29 -0
package/src/schema.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { MANIFEST_LIMITS, SEMVER_FORMAT, baseManifestSchema as manifestSchema } from "./base.js";
|
|
3
|
+
import { COLLECTION_NAME, FIELD_LIMITS, FIELD_NAME, FieldTypeInvalid, isSearchable, isSortable, isStructured, parseFieldType, RESERVED_FIELDS, } from "./field-types.js";
|
|
4
|
+
import { migrationsSchema } from "./migrations.js";
|
|
5
|
+
export { COLOUR_TOKENS, FIELD_LIMITS, parseFieldType, RESERVED_FIELDS, structuredFields, } from "./field-types.js";
|
|
6
|
+
/**
|
|
7
|
+
* What an app adds to `.brydio/app.json` to be more than a bundle of servers
|
|
8
|
+
* and skills: where it is shown, what it keeps, the tools that come from what
|
|
9
|
+
* it keeps, its screens, and what it asks the workspace for (contracts §4).
|
|
10
|
+
*
|
|
11
|
+
* Every key is optional, so every manifest E5 already imports still parses.
|
|
12
|
+
* A copy of Brydio's `apps/api/src/apps/manifest/manifest-ext.schema.ts`, the
|
|
13
|
+
* schema the server reads an installed app's manifest with. The two change
|
|
14
|
+
* together; `test/manifest.test.ts` parses the same manifests with both.
|
|
15
|
+
*
|
|
16
|
+
* Zod says what the additions *are*. The checks that are claims about one
|
|
17
|
+
* field against another — a search list naming a date, a placement naming a
|
|
18
|
+
* screen nobody declared — are `dataProblems` below, with a code each, so an
|
|
19
|
+
* import report can say which collection and which field, not just "invalid".
|
|
20
|
+
*/
|
|
21
|
+
const placementSchema = z.object({
|
|
22
|
+
kind: z.enum(['project-tab', 'project-sidebar', 'workspace-sidebar']),
|
|
23
|
+
screen: z.string().min(1).max(FIELD_LIMITS.nameChars),
|
|
24
|
+
label: z.string().min(1).max(60).optional(),
|
|
25
|
+
icon: z.string().max(60).optional(),
|
|
26
|
+
});
|
|
27
|
+
const collectionSchema = z.object({
|
|
28
|
+
/** Field name to type, in the manifest's spelling (`"member?"`, `["todo","done"]`). */
|
|
29
|
+
schema: z.record(z.string(), z.unknown()),
|
|
30
|
+
/** Fields the assistant may one day search by their words. */
|
|
31
|
+
search: z.array(z.string()).optional(),
|
|
32
|
+
/** The singular noun the tools are named with: `issue` gives `create_issue`. */
|
|
33
|
+
label: z.string().optional(),
|
|
34
|
+
});
|
|
35
|
+
const screenSchema = z.object({
|
|
36
|
+
/**
|
|
37
|
+
* Relative to the bundle's root: `screens/board.js` or `screens/board.mjs`.
|
|
38
|
+
* The server's schema said `.js` only while its store and the mount took
|
|
39
|
+
* `.mjs` too; the schema gives (E1 and Hodler, 16 Sep 14:04 and 14:12).
|
|
40
|
+
*/
|
|
41
|
+
entry: z
|
|
42
|
+
.string()
|
|
43
|
+
.min(1)
|
|
44
|
+
.max(200)
|
|
45
|
+
.regex(/^(?!\/)(?!.*\.\.)[A-Za-z0-9_\-./]+\.m?js$/, 'An entry is a .js or .mjs path inside the bundle.'),
|
|
46
|
+
});
|
|
47
|
+
/** Most custom tools one app may declare (A3-F08). */
|
|
48
|
+
export const MAX_CUSTOM_TOOLS = 20;
|
|
49
|
+
/** A tool name the model and a policy row can both use as it is. */
|
|
50
|
+
export const CUSTOM_TOOL_NAME = /^[a-z][a-z0-9_]{0,59}$/;
|
|
51
|
+
/**
|
|
52
|
+
* A tool the app writes itself (A3-F08-S01): a handler file in its bundle,
|
|
53
|
+
* run on Brydio's side in a box, taking `input` in the field-type grammar.
|
|
54
|
+
*/
|
|
55
|
+
const customToolSchema = z.object({
|
|
56
|
+
name: z.string().regex(CUSTOM_TOOL_NAME, 'A tool name is lower case letters, digits and underscores, starting with a letter.'),
|
|
57
|
+
description: z.string().min(1).max(1024),
|
|
58
|
+
/** Relative to the bundle's root: `handlers/list_prs.js`. */
|
|
59
|
+
handler: z
|
|
60
|
+
.string()
|
|
61
|
+
.min(1)
|
|
62
|
+
.max(200)
|
|
63
|
+
.regex(/^(?!\/)(?!.*\.\.)[A-Za-z0-9_\-./]+\.m?js$/, 'A handler is a .js or .mjs path inside the bundle.'),
|
|
64
|
+
/** Field name to type, as a collection's schema writes it (`"string?"`). */
|
|
65
|
+
input: z.record(z.string(), z.unknown()).optional(),
|
|
66
|
+
/** True when running it changes something: it asks first, like a generated write. */
|
|
67
|
+
write: z.boolean().optional(),
|
|
68
|
+
/** The collection it works on, when it works on one: its grant then needs that collection too. */
|
|
69
|
+
collection: z.string().optional(),
|
|
70
|
+
});
|
|
71
|
+
const toolsSchema = z.object({
|
|
72
|
+
/** Off only when the app supplies every tool itself (A3-F08). */
|
|
73
|
+
generated: z.boolean().optional(),
|
|
74
|
+
custom: z.array(customToolSchema).max(MAX_CUSTOM_TOOLS).optional(),
|
|
75
|
+
});
|
|
76
|
+
const grantsSchema = z.object({
|
|
77
|
+
tools: z.array(z.string().max(100)).max(200).optional(),
|
|
78
|
+
collections: z.array(z.string().max(100)).max(FIELD_LIMITS.collections + 1).optional(),
|
|
79
|
+
host: z.array(z.string().max(60)).max(20).optional(),
|
|
80
|
+
});
|
|
81
|
+
const extensionShape = {
|
|
82
|
+
placements: z.array(placementSchema).max(20).optional(),
|
|
83
|
+
data: z.record(z.string(), collectionSchema).optional(),
|
|
84
|
+
tools: toolsSchema.optional(),
|
|
85
|
+
screens: z.record(z.string(), screenSchema).optional(),
|
|
86
|
+
grants: grantsSchema.optional(),
|
|
87
|
+
/**
|
|
88
|
+
* How records move when the schema changes between versions (A3-F07).
|
|
89
|
+
* Checked against the previous version when a version is published, and
|
|
90
|
+
* again when a workspace's pin moves; here only its shape.
|
|
91
|
+
*/
|
|
92
|
+
migrations: migrationsSchema.optional(),
|
|
93
|
+
/**
|
|
94
|
+
* The `@brydio/app` version the bundle was built against, written by
|
|
95
|
+
* `brydio build` and never by hand. Optional, so a bundle from before it
|
|
96
|
+
* still loads; `POST /apps/publish` requires it and checks it (A5-F04-S03).
|
|
97
|
+
*/
|
|
98
|
+
sdk: z.string().max(MANIFEST_LIMITS.versionChars).regex(SEMVER_FORMAT).optional(),
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Everything wrong with an app's additions that a type cannot say.
|
|
102
|
+
*
|
|
103
|
+
* Exported on its own so E5's validator can fold the codes into its import
|
|
104
|
+
* report; the zod schemas below run it too, so a parse never accepts what
|
|
105
|
+
* this refuses.
|
|
106
|
+
*/
|
|
107
|
+
export function dataProblems(additions, options = {}) {
|
|
108
|
+
const problems = [];
|
|
109
|
+
const collections = Object.entries(additions.data ?? {});
|
|
110
|
+
if (collections.length > FIELD_LIMITS.collections) {
|
|
111
|
+
problems.push({
|
|
112
|
+
code: 'data_too_many_collections',
|
|
113
|
+
message: `An app may keep at most ${FIELD_LIMITS.collections} collections.`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
const labels = new Map();
|
|
117
|
+
for (const [collection, declared] of collections) {
|
|
118
|
+
if (!COLLECTION_NAME.test(collection) || collection.length > FIELD_LIMITS.nameChars) {
|
|
119
|
+
problems.push({
|
|
120
|
+
code: 'data_collection_name_format',
|
|
121
|
+
collection,
|
|
122
|
+
message: `${collection} is not a collection name: lower-case letters, digits and _, starting with a letter, at most ${FIELD_LIMITS.nameChars} characters.`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
const label = labelOf(collection, declared.label);
|
|
126
|
+
if (!COLLECTION_NAME.test(label) || label.length > FIELD_LIMITS.nameChars) {
|
|
127
|
+
problems.push({
|
|
128
|
+
code: 'data_label_format',
|
|
129
|
+
collection,
|
|
130
|
+
message: `${collection}'s label must be one lower-case word the tools can be named with, at most ${FIELD_LIMITS.nameChars} characters.`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
else if (labels.has(label)) {
|
|
134
|
+
// Two collections called "issue" would give two `create_issue` tools.
|
|
135
|
+
problems.push({
|
|
136
|
+
code: 'data_label_taken',
|
|
137
|
+
collection,
|
|
138
|
+
message: `${collection} and ${labels.get(label)} would both make tools called ${label}.`,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
labels.set(label, collection);
|
|
143
|
+
}
|
|
144
|
+
const fields = Object.entries(declared.schema);
|
|
145
|
+
if (fields.length > FIELD_LIMITS.fields) {
|
|
146
|
+
problems.push({
|
|
147
|
+
code: 'data_too_many_fields',
|
|
148
|
+
collection,
|
|
149
|
+
message: `A collection may have at most ${FIELD_LIMITS.fields} fields.`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
const types = new Map();
|
|
153
|
+
let projectField = null;
|
|
154
|
+
for (const [field, raw] of fields) {
|
|
155
|
+
if (RESERVED_FIELDS.has(field)) {
|
|
156
|
+
problems.push({
|
|
157
|
+
code: 'data_field_reserved',
|
|
158
|
+
collection,
|
|
159
|
+
field,
|
|
160
|
+
message: `${collection}.${field}: Brydio keeps ${field} on every record itself.`,
|
|
161
|
+
});
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (!FIELD_NAME.test(field) || field.length > FIELD_LIMITS.nameChars) {
|
|
165
|
+
problems.push({
|
|
166
|
+
code: 'data_field_name_format',
|
|
167
|
+
collection,
|
|
168
|
+
field,
|
|
169
|
+
message: `${collection}.${field} is not a field name: letters, digits and _, starting with a lower-case letter, at most ${FIELD_LIMITS.nameChars} characters.`,
|
|
170
|
+
});
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
const type = parseFieldType(raw);
|
|
175
|
+
types.set(field, type);
|
|
176
|
+
if (type.kind === 'project') {
|
|
177
|
+
// The record's project is copied to one indexed column, so a second
|
|
178
|
+
// `project` field would have nowhere to go.
|
|
179
|
+
if (projectField) {
|
|
180
|
+
problems.push({
|
|
181
|
+
code: 'data_project_field_twice',
|
|
182
|
+
collection,
|
|
183
|
+
field,
|
|
184
|
+
message: `${collection} links to a project twice (${projectField} and ${field}); keep one.`,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
projectField = field;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
if (!(error instanceof FieldTypeInvalid))
|
|
192
|
+
throw error;
|
|
193
|
+
problems.push({
|
|
194
|
+
code: error.code,
|
|
195
|
+
collection,
|
|
196
|
+
field,
|
|
197
|
+
message: `${collection}.${field}: ${error.message}`,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
for (const field of declared.search ?? []) {
|
|
202
|
+
const type = types.get(field);
|
|
203
|
+
if (!type) {
|
|
204
|
+
problems.push({
|
|
205
|
+
code: 'data_search_unknown_field',
|
|
206
|
+
collection,
|
|
207
|
+
field,
|
|
208
|
+
message: `${collection} searches ${field}, which is not one of its fields.`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
else if (!isSearchable(type)) {
|
|
212
|
+
problems.push({
|
|
213
|
+
code: 'data_search_not_text',
|
|
214
|
+
collection,
|
|
215
|
+
field,
|
|
216
|
+
message: `${collection}.${field} cannot be searched: only text fields can.`,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// What the app keeps must be what it asks to keep (A3-F06-S01): a
|
|
222
|
+
// collection the grants leave out would be data a workspace never agreed to
|
|
223
|
+
// hold, found only when the first write is refused.
|
|
224
|
+
const granted = additions.grants?.collections ?? [];
|
|
225
|
+
if (options.grants !== false && !granted.includes('*')) {
|
|
226
|
+
for (const [collection] of collections) {
|
|
227
|
+
if (!granted.includes(collection)) {
|
|
228
|
+
problems.push({
|
|
229
|
+
code: 'grant_collection_missing',
|
|
230
|
+
collection,
|
|
231
|
+
message: `${collection} is kept but not asked for: add it to grants.collections.`,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
problems.push(...customToolProblems(additions, options));
|
|
237
|
+
const screens = new Set(Object.keys(additions.screens ?? {}));
|
|
238
|
+
for (const placement of additions.placements ?? []) {
|
|
239
|
+
if (!screens.has(placement.screen)) {
|
|
240
|
+
problems.push({
|
|
241
|
+
code: 'placement_screen_unknown',
|
|
242
|
+
message: `A ${placement.kind} placement opens "${placement.screen}", which is not one of the app's screens.`,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return problems;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* A custom tool must be granted by name (or `*`), take a name no generated
|
|
250
|
+
* tool has, name a collection the app keeps, and take input in the field-type
|
|
251
|
+
* grammar (A3-F08-S01). That its handler file is in the bundle is checked
|
|
252
|
+
* where the files are: `POST /apps/publish`.
|
|
253
|
+
*/
|
|
254
|
+
function customToolProblems(additions, options) {
|
|
255
|
+
const problems = [];
|
|
256
|
+
const custom = additions.tools?.custom ?? [];
|
|
257
|
+
// Named from the manifest directly: `collectionsOf` validates through this
|
|
258
|
+
// very check, and would come back here.
|
|
259
|
+
const generated = additions.tools?.generated === false
|
|
260
|
+
? new Set()
|
|
261
|
+
: new Set(Object.entries(additions.data ?? {}).flatMap(([name, declared]) => {
|
|
262
|
+
const label = labelOf(name, declared.label);
|
|
263
|
+
const plural = `${label}s`;
|
|
264
|
+
return [
|
|
265
|
+
`create_${label}`,
|
|
266
|
+
`update_${label}`,
|
|
267
|
+
`get_${label}`,
|
|
268
|
+
`delete_${label}`,
|
|
269
|
+
`list_${plural}`,
|
|
270
|
+
`search_${plural}`,
|
|
271
|
+
`batch_${plural}`,
|
|
272
|
+
];
|
|
273
|
+
}));
|
|
274
|
+
const seen = new Set();
|
|
275
|
+
const granted = additions.grants?.tools ?? [];
|
|
276
|
+
for (const tool of custom) {
|
|
277
|
+
if (generated.has(tool.name) || seen.has(tool.name)) {
|
|
278
|
+
problems.push({
|
|
279
|
+
code: 'custom_name_taken',
|
|
280
|
+
message: `${tool.name} is already a tool this app has: give the custom tool another name, or switch generated tools off.`,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
seen.add(tool.name);
|
|
284
|
+
if (tool.collection !== undefined && !additions.data?.[tool.collection]) {
|
|
285
|
+
problems.push({
|
|
286
|
+
code: 'custom_collection_unknown',
|
|
287
|
+
collection: tool.collection,
|
|
288
|
+
message: `${tool.name} works on ${tool.collection}, which the app does not keep.`,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
for (const [field, raw] of Object.entries(tool.input ?? {})) {
|
|
292
|
+
try {
|
|
293
|
+
parseFieldType(raw);
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
problems.push({
|
|
297
|
+
code: 'custom_input_invalid',
|
|
298
|
+
field,
|
|
299
|
+
message: `${tool.name}'s input ${field}: ${error instanceof Error ? error.message : 'not a field type'}`,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (options.grants !== false && !granted.includes('*') && !granted.includes(tool.name)) {
|
|
304
|
+
problems.push({
|
|
305
|
+
code: 'grant_tool_missing',
|
|
306
|
+
message: `${tool.name} is a custom tool but not asked for: add it to grants.tools.`,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return problems;
|
|
311
|
+
}
|
|
312
|
+
const refuse = (additions, ctx, options = {}) => {
|
|
313
|
+
for (const problem of dataProblems(additions, options)) {
|
|
314
|
+
ctx.addIssue({
|
|
315
|
+
code: 'custom',
|
|
316
|
+
message: problem.message,
|
|
317
|
+
path: problem.code === 'grant_tool_missing'
|
|
318
|
+
? ['grants', 'tools']
|
|
319
|
+
: problem.code.startsWith('grant_')
|
|
320
|
+
? ['grants', 'collections']
|
|
321
|
+
: problem.code.startsWith('custom_')
|
|
322
|
+
? ['tools', 'custom']
|
|
323
|
+
: problem.collection
|
|
324
|
+
? ['data', problem.collection, ...(problem.field ? ['schema', problem.field] : [])]
|
|
325
|
+
: [],
|
|
326
|
+
params: { code: problem.code },
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
/** The additions alone, for code that has the rest of the manifest already. */
|
|
331
|
+
export const manifestExtensionsSchema = z.object(extensionShape).superRefine((additions, ctx) => refuse(additions, ctx));
|
|
332
|
+
/**
|
|
333
|
+
* The additions as code reads a manifest that was checked whole when it was
|
|
334
|
+
* published. Everything but the grants cross-check: what is granted is the
|
|
335
|
+
* install's record to answer (A3-F06), not the stored manifest's.
|
|
336
|
+
*/
|
|
337
|
+
export const storedExtensionsSchema = z.object(extensionShape).superRefine((additions, ctx) => refuse(additions, ctx, { grants: false }));
|
|
338
|
+
/** E5's manifest with the additions: the whole `.brydio/app.json` of an app. */
|
|
339
|
+
export const appManifestSchema = manifestSchema.extend(extensionShape).superRefine((additions, ctx) => refuse(additions, ctx));
|
|
340
|
+
/**
|
|
341
|
+
* The singular a collection's tools are named with: the manifest's, else the
|
|
342
|
+
* collection's name with a trailing "s" taken off (`issues` → `issue`).
|
|
343
|
+
*/
|
|
344
|
+
export function labelOf(collection, label) {
|
|
345
|
+
if (label)
|
|
346
|
+
return label;
|
|
347
|
+
return collection.length > 1 && collection.endsWith('s') ? collection.slice(0, -1) : collection;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Every collection an app declares, parsed. Throws on a manifest that
|
|
351
|
+
* `dataProblems` would refuse: callers read manifests that were validated
|
|
352
|
+
* when they were stored, and a bad one here is a bug, not input.
|
|
353
|
+
*/
|
|
354
|
+
export function collectionsOf(manifest) {
|
|
355
|
+
const parsed = storedExtensionsSchema.parse({ data: manifest.data ?? {} });
|
|
356
|
+
return Object.entries(parsed.data ?? {}).map(([name, declared]) => {
|
|
357
|
+
const fields = Object.fromEntries(Object.entries(declared.schema).map(([field, raw]) => [field, parseFieldType(raw)]));
|
|
358
|
+
const label = labelOf(name, declared.label);
|
|
359
|
+
const names = Object.keys(fields);
|
|
360
|
+
return {
|
|
361
|
+
name,
|
|
362
|
+
label,
|
|
363
|
+
plural: `${label}s`,
|
|
364
|
+
fields,
|
|
365
|
+
structured: names.filter(field => isStructured(fields[field])),
|
|
366
|
+
sortable: names.filter(field => isSortable(fields[field])),
|
|
367
|
+
search: declared.search ?? [],
|
|
368
|
+
projectField: names.find(field => fields[field].kind === 'project') ?? null,
|
|
369
|
+
};
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
/** One collection by name, or null when the app keeps no such thing. */
|
|
373
|
+
export function collectionOf(manifest, collection) {
|
|
374
|
+
return collectionsOf(manifest).find(one => one.name === collection) ?? null;
|
|
375
|
+
}
|
package/src/sdk.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which SDK built a version, and whether a Brydio runs it (A5-F04-S03).
|
|
3
|
+
*
|
|
4
|
+
* `brydio build` writes the `@brydio/app` version it built against into the
|
|
5
|
+
* bundle's `app.json` as `sdk`. Brydio says which versions it runs at
|
|
6
|
+
* `GET /api/v1/apps/sdk` as `{ oldest, before }`, and refuses a publish
|
|
7
|
+
* outside them as `sdk_unsupported` (422, `at: "sdk"`). `brydio publish` asks
|
|
8
|
+
* first and refuses with the same sentence before uploading anything.
|
|
9
|
+
*
|
|
10
|
+
* Copies of the server's `apps/api/src/apps/publishing/sdk-support.ts`
|
|
11
|
+
* (`sdkRefusal`, given the range rather than reading the host's constant) and
|
|
12
|
+
* `apps/api/src/apps/versions/compare-versions.ts`, word for word;
|
|
13
|
+
* `manifest.test.ts` compares them whenever a Brydio checkout sits beside
|
|
14
|
+
* this repository.
|
|
15
|
+
*/
|
|
16
|
+
/** The SDK versions a Brydio runs: from `oldest`, up to but not including `before`. */
|
|
17
|
+
export interface SdkSupport {
|
|
18
|
+
oldest: string;
|
|
19
|
+
before: string;
|
|
20
|
+
}
|
|
21
|
+
/** Null when an app built with this SDK can run on a Brydio with this support; otherwise the sentence why not. */
|
|
22
|
+
export declare function sdkRefusal(sdk: unknown, support: SdkSupport): string | null;
|
|
23
|
+
/**
|
|
24
|
+
* Which of two version numbers is newer, by semver's precedence rules. Build
|
|
25
|
+
* metadata (`+abc`) is ignored, as semver says.
|
|
26
|
+
*
|
|
27
|
+
* Negative when `a` is older, positive when newer, zero when they are the
|
|
28
|
+
* same release.
|
|
29
|
+
*/
|
|
30
|
+
export declare function compareVersions(a: string, b: string): number;
|
package/src/sdk.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which SDK built a version, and whether a Brydio runs it (A5-F04-S03).
|
|
3
|
+
*
|
|
4
|
+
* `brydio build` writes the `@brydio/app` version it built against into the
|
|
5
|
+
* bundle's `app.json` as `sdk`. Brydio says which versions it runs at
|
|
6
|
+
* `GET /api/v1/apps/sdk` as `{ oldest, before }`, and refuses a publish
|
|
7
|
+
* outside them as `sdk_unsupported` (422, `at: "sdk"`). `brydio publish` asks
|
|
8
|
+
* first and refuses with the same sentence before uploading anything.
|
|
9
|
+
*
|
|
10
|
+
* Copies of the server's `apps/api/src/apps/publishing/sdk-support.ts`
|
|
11
|
+
* (`sdkRefusal`, given the range rather than reading the host's constant) and
|
|
12
|
+
* `apps/api/src/apps/versions/compare-versions.ts`, word for word;
|
|
13
|
+
* `manifest.test.ts` compares them whenever a Brydio checkout sits beside
|
|
14
|
+
* this repository.
|
|
15
|
+
*/
|
|
16
|
+
/** Null when an app built with this SDK can run on a Brydio with this support; otherwise the sentence why not. */
|
|
17
|
+
export function sdkRefusal(sdk, support) {
|
|
18
|
+
const range = `Brydio runs apps built with SDK ${support.oldest} or newer, before ${support.before}.`;
|
|
19
|
+
if (typeof sdk !== 'string' || sdk.length === 0) {
|
|
20
|
+
return `app.json does not say which SDK built it. Build it with brydio build. ${range}`;
|
|
21
|
+
}
|
|
22
|
+
// `before` excludes its own prereleases too: 0.2.0-beta is not a 0.1 SDK.
|
|
23
|
+
if (compareVersions(sdk, support.oldest) < 0 || compareVersions(sdk, `${support.before}-0`) >= 0) {
|
|
24
|
+
return `This app was built with SDK ${sdk}. ${range}`;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Which of two version numbers is newer, by semver's precedence rules. Build
|
|
30
|
+
* metadata (`+abc`) is ignored, as semver says.
|
|
31
|
+
*
|
|
32
|
+
* Negative when `a` is older, positive when newer, zero when they are the
|
|
33
|
+
* same release.
|
|
34
|
+
*/
|
|
35
|
+
export function compareVersions(a, b) {
|
|
36
|
+
const [coreA, preA] = split(a);
|
|
37
|
+
const [coreB, preB] = split(b);
|
|
38
|
+
for (let i = 0; i < 3; i++) {
|
|
39
|
+
const difference = (coreA[i] ?? 0) - (coreB[i] ?? 0);
|
|
40
|
+
if (difference !== 0)
|
|
41
|
+
return Math.sign(difference);
|
|
42
|
+
}
|
|
43
|
+
// A release is newer than any of its prereleases.
|
|
44
|
+
if (preA.length === 0 || preB.length === 0)
|
|
45
|
+
return Math.sign(preB.length - preA.length);
|
|
46
|
+
for (let i = 0; i < Math.max(preA.length, preB.length); i++) {
|
|
47
|
+
const x = preA[i];
|
|
48
|
+
const y = preB[i];
|
|
49
|
+
// A longer prerelease with the same start is newer: 1.0.0-beta < 1.0.0-beta.1.
|
|
50
|
+
if (x === undefined)
|
|
51
|
+
return -1;
|
|
52
|
+
if (y === undefined)
|
|
53
|
+
return 1;
|
|
54
|
+
const numericX = /^\d+$/.test(x);
|
|
55
|
+
const numericY = /^\d+$/.test(y);
|
|
56
|
+
if (numericX && numericY) {
|
|
57
|
+
const difference = Number(x) - Number(y);
|
|
58
|
+
if (difference !== 0)
|
|
59
|
+
return Math.sign(difference);
|
|
60
|
+
}
|
|
61
|
+
else if (numericX !== numericY) {
|
|
62
|
+
// Numbers sort before words: 1.0.0-1 < 1.0.0-alpha.
|
|
63
|
+
return numericX ? -1 : 1;
|
|
64
|
+
}
|
|
65
|
+
else if (x !== y) {
|
|
66
|
+
return x < y ? -1 : 1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
function split(version) {
|
|
72
|
+
const withoutBuild = version.split('+')[0];
|
|
73
|
+
const dash = withoutBuild.indexOf('-');
|
|
74
|
+
const core = dash === -1 ? withoutBuild : withoutBuild.slice(0, dash);
|
|
75
|
+
const pre = dash === -1 ? '' : withoutBuild.slice(dash + 1);
|
|
76
|
+
return [core.split('.').map(Number), pre ? pre.split('.') : []];
|
|
77
|
+
}
|
package/src/secrets.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding a secret somebody shipped inside a bundle (A8-F04-S03).
|
|
3
|
+
*
|
|
4
|
+
* A copy of Brydio's `apps/api/src/extensions/apps/secret-scan.ts`, which
|
|
5
|
+
* `POST /apps/publish` runs over every upload and refuses as
|
|
6
|
+
* `secret_in_bundle`. The same keys, the same idea of a placeholder, the same
|
|
7
|
+
* two places looked in, and the same sentence, so `brydio validate` refuses
|
|
8
|
+
* exactly what the server would and nothing else.
|
|
9
|
+
*
|
|
10
|
+
* The scan is deliberately narrow, as the server's is. It looks only where a
|
|
11
|
+
* package *declares* credentials, `servers.json` and `integrations/*.json`,
|
|
12
|
+
* where a value is unambiguous, and in the bundle's own `app.json`
|
|
13
|
+
* (`secretsInJson`), rather than grepping scripts for things that look like
|
|
14
|
+
* keys: a pattern scan of minified code misses real keys and refuses innocent
|
|
15
|
+
* strings (Hodler, 16 Sep).
|
|
16
|
+
*/
|
|
17
|
+
/** The server's sentence for `brydio_secret_in_package`, which publishing sends as `secret_in_bundle`. */
|
|
18
|
+
export declare const SECRET_MESSAGE = "That package contains a secret. Header values and client secrets are entered here, never shipped in a file \u2014 remove it and import again.";
|
|
19
|
+
export interface SecretFound {
|
|
20
|
+
code: 'secret_in_bundle';
|
|
21
|
+
message: string;
|
|
22
|
+
/** Where: the file and the path inside it. The value itself is never carried. */
|
|
23
|
+
path: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Every secret-shaped value in one JSON document, by the same keys and the
|
|
27
|
+
* same placeholder rules: for a bundle's `app.json`, which Brydio serves to
|
|
28
|
+
* every screen that opens it and scans at publish (`secretsInJson`).
|
|
29
|
+
*/
|
|
30
|
+
export declare function secretsInJson(document: unknown, at: string): SecretFound[];
|
|
31
|
+
/** Every secret declared in a bundle's files, as the server finds them. */
|
|
32
|
+
export declare function findSecrets(files: ReadonlyMap<string, Uint8Array>, root?: string): SecretFound[];
|
package/src/secrets.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding a secret somebody shipped inside a bundle (A8-F04-S03).
|
|
3
|
+
*
|
|
4
|
+
* A copy of Brydio's `apps/api/src/extensions/apps/secret-scan.ts`, which
|
|
5
|
+
* `POST /apps/publish` runs over every upload and refuses as
|
|
6
|
+
* `secret_in_bundle`. The same keys, the same idea of a placeholder, the same
|
|
7
|
+
* two places looked in, and the same sentence, so `brydio validate` refuses
|
|
8
|
+
* exactly what the server would and nothing else.
|
|
9
|
+
*
|
|
10
|
+
* The scan is deliberately narrow, as the server's is. It looks only where a
|
|
11
|
+
* package *declares* credentials, `servers.json` and `integrations/*.json`,
|
|
12
|
+
* where a value is unambiguous, and in the bundle's own `app.json`
|
|
13
|
+
* (`secretsInJson`), rather than grepping scripts for things that look like
|
|
14
|
+
* keys: a pattern scan of minified code misses real keys and refuses innocent
|
|
15
|
+
* strings (Hodler, 16 Sep).
|
|
16
|
+
*/
|
|
17
|
+
/** The server's sentence for `brydio_secret_in_package`, which publishing sends as `secret_in_bundle`. */
|
|
18
|
+
export const SECRET_MESSAGE = 'That package contains a secret. Header values and client secrets are entered here, never shipped in a file — remove it and import again.';
|
|
19
|
+
/** The keys that hold a value rather than a name, in either declaration. */
|
|
20
|
+
const VALUE_KEYS = new Set(['value', 'clientSecret', 'client_secret', 'secret', 'apiKey', 'api_key', 'key', 'token', 'password']);
|
|
21
|
+
/** A value that is actually one, rather than a placeholder asking to be filled. */
|
|
22
|
+
const isRealValue = (value) => {
|
|
23
|
+
if (typeof value !== 'string')
|
|
24
|
+
return false;
|
|
25
|
+
const trimmed = value.trim();
|
|
26
|
+
if (!trimmed)
|
|
27
|
+
return false;
|
|
28
|
+
if (/^\$\{[^}]*\}$/.test(trimmed))
|
|
29
|
+
return false;
|
|
30
|
+
if (/^[<{[].*[>}\]]$/.test(trimmed))
|
|
31
|
+
return false;
|
|
32
|
+
if (/^(your|my|the)[ _-]/i.test(trimmed))
|
|
33
|
+
return false;
|
|
34
|
+
if (/^(x+|\*+|\.+|todo|tbd|changeme|redacted|placeholder)$/i.test(trimmed))
|
|
35
|
+
return false;
|
|
36
|
+
return true;
|
|
37
|
+
};
|
|
38
|
+
function walk(node, at, found) {
|
|
39
|
+
if (Array.isArray(node)) {
|
|
40
|
+
node.forEach((one, index) => walk(one, `${at}[${index}]`, found));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (!node || typeof node !== 'object')
|
|
44
|
+
return;
|
|
45
|
+
for (const [key, value] of Object.entries(node)) {
|
|
46
|
+
const path = at ? `${at}.${key}` : key;
|
|
47
|
+
if (VALUE_KEYS.has(key) && isRealValue(value)) {
|
|
48
|
+
// Never quoted back: a report that repeated the secret would copy it somewhere else.
|
|
49
|
+
found.push({ code: 'secret_in_bundle', message: SECRET_MESSAGE, path });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
walk(value, path, found);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Every secret-shaped value in one JSON document, by the same keys and the
|
|
57
|
+
* same placeholder rules: for a bundle's `app.json`, which Brydio serves to
|
|
58
|
+
* every screen that opens it and scans at publish (`secretsInJson`).
|
|
59
|
+
*/
|
|
60
|
+
export function secretsInJson(document, at) {
|
|
61
|
+
const found = [];
|
|
62
|
+
walk(document, at, found);
|
|
63
|
+
return found;
|
|
64
|
+
}
|
|
65
|
+
/** Every secret declared in a bundle's files, as the server finds them. */
|
|
66
|
+
export function findSecrets(files, root = '') {
|
|
67
|
+
const found = [];
|
|
68
|
+
for (const [path, bytes] of files) {
|
|
69
|
+
const relative = root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
|
|
70
|
+
const declares = relative === 'servers.json' || (relative.startsWith('integrations/') && relative.endsWith('.json'));
|
|
71
|
+
if (!declares)
|
|
72
|
+
continue;
|
|
73
|
+
try {
|
|
74
|
+
walk(JSON.parse(new TextDecoder().decode(bytes)), relative, found);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// A file that will not parse is refused elsewhere.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return found;
|
|
81
|
+
}
|
package/src/tools.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type CollectionSpec, type ManifestExtensions } from './schema.js';
|
|
2
|
+
/**
|
|
3
|
+
* The names of the tools Brydio generates for a collection (contracts §6).
|
|
4
|
+
*
|
|
5
|
+
* As `apps/api/src/apps/tools/generated-tools.ts` names them: the singular
|
|
6
|
+
* label for one record, and the label with an "s" for many, so a collection
|
|
7
|
+
* labelled `issue` gets `create_issue` and `list_issues`. The plural is the
|
|
8
|
+
* label's, not the collection's name, which is what the server does.
|
|
9
|
+
*/
|
|
10
|
+
export type ToolVerb = 'create' | 'update' | 'get' | 'list' | 'search' | 'delete';
|
|
11
|
+
/** Whether the tool writes, and so asks the person first from a screen (§6, §7). */
|
|
12
|
+
export declare const TOOL_WRITES: Readonly<Record<ToolVerb, boolean>>;
|
|
13
|
+
export declare function toolNames(spec: Pick<CollectionSpec, 'label' | 'plural'>): Record<ToolVerb, string>;
|
|
14
|
+
export interface GeneratedTool {
|
|
15
|
+
name: string;
|
|
16
|
+
verb: ToolVerb;
|
|
17
|
+
collection: string;
|
|
18
|
+
write: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** Every generated tool of a manifest's collections, or none when generation is off. */
|
|
21
|
+
export declare function generatedToolsOf(manifest: Pick<ManifestExtensions, 'data' | 'tools'>): GeneratedTool[];
|
package/src/tools.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { collectionsOf } from "./schema.js";
|
|
2
|
+
/** Whether the tool writes, and so asks the person first from a screen (§6, §7). */
|
|
3
|
+
export const TOOL_WRITES = {
|
|
4
|
+
create: true,
|
|
5
|
+
update: true,
|
|
6
|
+
get: false,
|
|
7
|
+
list: false,
|
|
8
|
+
search: false,
|
|
9
|
+
delete: true,
|
|
10
|
+
};
|
|
11
|
+
export function toolNames(spec) {
|
|
12
|
+
return {
|
|
13
|
+
create: `create_${spec.label}`,
|
|
14
|
+
update: `update_${spec.label}`,
|
|
15
|
+
get: `get_${spec.label}`,
|
|
16
|
+
list: `list_${spec.plural}`,
|
|
17
|
+
search: `search_${spec.plural}`,
|
|
18
|
+
delete: `delete_${spec.label}`,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** Every generated tool of a manifest's collections, or none when generation is off. */
|
|
22
|
+
export function generatedToolsOf(manifest) {
|
|
23
|
+
if (manifest.tools?.generated === false)
|
|
24
|
+
return [];
|
|
25
|
+
return collectionsOf(manifest).flatMap(spec => Object.entries(toolNames(spec)).map(([verb, name]) => ({
|
|
26
|
+
name,
|
|
27
|
+
verb,
|
|
28
|
+
collection: spec.name,
|
|
29
|
+
write: TOOL_WRITES[verb],
|
|
30
|
+
})));
|
|
31
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type AppManifestWithData, type DataProblemCode } from './schema.js';
|
|
2
|
+
/**
|
|
3
|
+
* Reading a manifest and saying what is wrong with it, the way Brydio would.
|
|
4
|
+
*
|
|
5
|
+
* The answer is the schema's: a manifest is valid here exactly when
|
|
6
|
+
* `appManifestSchema` accepts it, which is the server's schema, copied. Each
|
|
7
|
+
* problem keeps the server's code where it has one (`data_label_taken`,
|
|
8
|
+
* `placement_screen_unknown`), and a field that is simply the wrong shape is
|
|
9
|
+
* `manifest_invalid` with the path to it, so an app's builder can find the
|
|
10
|
+
* line.
|
|
11
|
+
*/
|
|
12
|
+
export type ManifestProblemCode = DataProblemCode | 'manifest_not_json' | 'manifest_invalid';
|
|
13
|
+
export interface ManifestProblem {
|
|
14
|
+
code: ManifestProblemCode;
|
|
15
|
+
message: string;
|
|
16
|
+
/** Where in the manifest, like `placements.0.screen`. */
|
|
17
|
+
path?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface ManifestValidation {
|
|
20
|
+
ok: boolean;
|
|
21
|
+
/** Present only when `ok`: the manifest as Brydio parses it. */
|
|
22
|
+
manifest?: AppManifestWithData;
|
|
23
|
+
problems: ManifestProblem[];
|
|
24
|
+
}
|
|
25
|
+
export declare function validateManifest(source: unknown): ManifestValidation;
|
|
26
|
+
/** Parses the text of `.brydio/app.json` and validates it. */
|
|
27
|
+
export declare function validateManifestText(text: string): ManifestValidation;
|