@brydio/manifest 0.1.0-alpha.6 → 0.1.0-alpha.61
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 +1 -1
- package/package.json +6 -2
- package/src/anonymous.d.ts +42 -0
- package/src/anonymous.js +86 -0
- package/src/base.d.ts +17 -1
- package/src/base.js +13 -1
- package/src/bundle.d.ts +22 -2
- package/src/bundle.js +93 -11
- package/src/confirm-email.d.ts +40 -0
- package/src/confirm-email.js +78 -0
- package/src/define.d.ts +1 -1
- package/src/define.js +1 -1
- package/src/field-types.d.ts +41 -5
- package/src/field-types.js +60 -10
- package/src/frame-checks.d.ts +13 -0
- package/src/frame-checks.js +35 -0
- package/src/grants.d.ts +14 -2
- package/src/grants.js +34 -2
- package/src/index.d.ts +13 -5
- package/src/index.js +15 -5
- package/src/look.d.ts +196 -0
- package/src/look.js +744 -0
- package/src/migrations.d.ts +1 -1
- package/src/migrations.js +29 -0
- package/src/open-schema.d.ts +128 -0
- package/src/open-schema.js +444 -0
- package/src/readers.d.ts +54 -0
- package/src/readers.js +96 -0
- package/src/requirements.d.ts +122 -0
- package/src/requirements.js +692 -0
- package/src/schema.d.ts +576 -9
- package/src/schema.js +484 -31
- package/src/screen-kinds.d.ts +18 -0
- package/src/screen-kinds.js +30 -0
- package/src/secrets.d.ts +14 -0
- package/src/secrets.js +38 -5
- package/src/validate.d.ts +12 -3
- package/src/validate.js +7 -5
package/src/schema.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { MANIFEST_LIMITS, SEMVER_FORMAT, baseManifestSchema as manifestSchema } from "./base.js";
|
|
3
3
|
import { COLLECTION_NAME, FIELD_LIMITS, FIELD_NAME, FieldTypeInvalid, isSearchable, isSortable, isStructured, parseFieldType, RESERVED_FIELDS, } from "./field-types.js";
|
|
4
|
+
import { themeProblems } from "./look.js";
|
|
5
|
+
import { SCREEN_KINDS } from "./screen-kinds.js";
|
|
4
6
|
import { migrationsSchema } from "./migrations.js";
|
|
7
|
+
import { openSchemaProblems } from "./open-schema.js";
|
|
8
|
+
import { confirmEmailProblems, confirmEmailSchema } from "./confirm-email.js";
|
|
9
|
+
import { anonymousProblems, anonymousSchema, anonymousSpec } from "./anonymous.js";
|
|
10
|
+
import { editorsProblems, editorsSchema, READERS_LIMIT, readersProblems, readersSchema } from "./readers.js";
|
|
5
11
|
export { COLOUR_TOKENS, FIELD_LIMITS, parseFieldType, RESERVED_FIELDS, structuredFields, } from "./field-types.js";
|
|
6
12
|
/**
|
|
7
13
|
* What an app adds to `.brydio/app.json` to be more than a bundle of servers
|
|
@@ -24,12 +30,88 @@ const placementSettingSchema = z.object({
|
|
|
24
30
|
required: z.boolean().optional(),
|
|
25
31
|
placeholder: z.string().max(200).optional(),
|
|
26
32
|
});
|
|
33
|
+
/**
|
|
34
|
+
* How deep a folder's rows may nest, counting its own rows as the first
|
|
35
|
+
* level (ADR-A21): Issues → sprints → issues is two. Each level is listed
|
|
36
|
+
* only when a person opens its parent, so depth costs nothing while closed;
|
|
37
|
+
* the limit is for the sidebar's width and a person's patience.
|
|
38
|
+
*/
|
|
39
|
+
export const MAX_FOLDER_DEPTH = 4;
|
|
40
|
+
/**
|
|
41
|
+
* "New sprint" on a folder's level (ADR-A21): one of the app's tools, run
|
|
42
|
+
* through the screen's door with the name the person typed in `titleField`
|
|
43
|
+
* and, below the first level, the row it is created under in `parentField`.
|
|
44
|
+
*/
|
|
45
|
+
const folderCreateSchema = z.object({
|
|
46
|
+
tool: z.string().min(1).max(64),
|
|
47
|
+
/** "sprint", in "New sprint". */
|
|
48
|
+
noun: z.string().min(1).max(40).optional(),
|
|
49
|
+
titleField: z.string().regex(FIELD_NAME, 'A field name is letters, digits and _, starting with a lower-case letter.').optional(),
|
|
50
|
+
parentField: z.string().regex(FIELD_NAME, 'A field name is letters, digits and _, starting with a lower-case letter.').optional(),
|
|
51
|
+
});
|
|
52
|
+
/** A level below a folder's own rows: what they are called, and how one is made. */
|
|
53
|
+
const folderLevelSchema = z.object({
|
|
54
|
+
noun: z.string().min(1).max(60).optional(),
|
|
55
|
+
create: folderCreateSchema.optional(),
|
|
56
|
+
});
|
|
57
|
+
/** The sizes a Home card or a project widget can be drawn at (CC11-F10). */
|
|
58
|
+
export const HOME_CARD_SIZES = ['small', 'medium', 'large'];
|
|
59
|
+
/** The kinds drawn as a card, which say the sizes they draw well at. */
|
|
60
|
+
export const SIZED_PLACEMENT_KINDS = ['home', 'project-widget'];
|
|
61
|
+
/** A stable identity for one independently addable placement offering. */
|
|
62
|
+
export const PLACEMENT_KEY = /^[a-z][a-z0-9-]{0,39}$/;
|
|
63
|
+
export function effectivePlacementKey(one) {
|
|
64
|
+
return one.key ?? `${one.kind}:${one.screen}`;
|
|
65
|
+
}
|
|
27
66
|
const placementSchema = z.object({
|
|
28
|
-
|
|
67
|
+
key: z.string().regex(PLACEMENT_KEY).optional(),
|
|
68
|
+
/**
|
|
69
|
+
* `home`: a card on a person's Home, drawn by `screen` at one of `sizes`.
|
|
70
|
+
* `project-widget`: a card on a project's page, drawn at one of `sizes`.
|
|
71
|
+
* `project-tab` is retired (30 Sep 2026): still read from a published
|
|
72
|
+
* manifest, refused in a new one (`placement_tab_retired`).
|
|
73
|
+
* `public-page`: a screen people without a Brydio account open at the
|
|
74
|
+
* page's own address, once an admin turns it on (P3). It has no `sizes`,
|
|
75
|
+
* `children` or `settings` (`placement_public_shape`), and it reaches only
|
|
76
|
+
* collections marked `publicRead` or `publicSubmit` and tools marked
|
|
77
|
+
* `public`.
|
|
78
|
+
* `chat-card`: a screen drawn inside a chat message the app posted with
|
|
79
|
+
* a handler's `chat.post` (FO03). Nobody places it by hand, so it has no
|
|
80
|
+
* `sizes`, `children` or `settings` (`placement_chat_card_shape`); its
|
|
81
|
+
* screen sees `placement.kind === 'chat-card'` and the card's `route`.
|
|
82
|
+
* `person-profile`: a panel on every person's Directory profile, for
|
|
83
|
+
* viewers who can read the instance. Nobody places it by hand, so it has no
|
|
84
|
+
* `sizes`, `children` or `settings` (`placement_person_profile_shape`); its
|
|
85
|
+
* screen sees `placement.personId`, and reads only what the viewer may.
|
|
86
|
+
*/
|
|
87
|
+
kind: z.enum(['project-tab', 'project-widget', 'project-sidebar', 'workspace-sidebar', 'home', 'public-page', 'chat-card', 'person-profile']),
|
|
29
88
|
screen: z.string().min(1).max(FIELD_LIMITS.nameChars),
|
|
89
|
+
/** For `home` and `project-widget` only, and required there: the sizes the screen draws well at. */
|
|
90
|
+
sizes: z.array(z.enum(HOME_CARD_SIZES)).max(3).optional(),
|
|
30
91
|
label: z.string().min(1).max(60).optional(),
|
|
31
92
|
icon: z.string().max(60).optional(),
|
|
32
93
|
settings: z.record(z.string().regex(FIELD_NAME), placementSettingSchema).optional(),
|
|
94
|
+
/**
|
|
95
|
+
* A folder (A2-F02-S02): a sidebar item whose rows one of the app's read
|
|
96
|
+
* tools lists live, only while it is open. It can nest, and offer "New …"
|
|
97
|
+
* at each level, when the app opts in (ADR-A21).
|
|
98
|
+
*/
|
|
99
|
+
children: z
|
|
100
|
+
.object({
|
|
101
|
+
tool: z.string().min(1).max(64),
|
|
102
|
+
refreshSeconds: z.number().int().min(15).max(3600).optional(),
|
|
103
|
+
cap: z.number().int().min(1).max(50).optional(),
|
|
104
|
+
noun: z.string().min(1).max(60).optional(),
|
|
105
|
+
/** A "New …" row at the folder's first level (ADR-A21). */
|
|
106
|
+
create: folderCreateSchema.optional(),
|
|
107
|
+
/**
|
|
108
|
+
* The levels under the first, in order (ADR-A21). A row the tool marks
|
|
109
|
+
* `hasChildren` opens only when a level is declared for it; without
|
|
110
|
+
* `nested` the folder stays one flat list.
|
|
111
|
+
*/
|
|
112
|
+
nested: z.array(folderLevelSchema).max(8).optional(),
|
|
113
|
+
})
|
|
114
|
+
.optional(),
|
|
33
115
|
});
|
|
34
116
|
const collectionSchema = z.object({
|
|
35
117
|
/** Field name to type, in the manifest's spelling (`"member?"`, `["todo","done"]`). */
|
|
@@ -38,21 +120,81 @@ const collectionSchema = z.object({
|
|
|
38
120
|
search: z.array(z.string()).optional(),
|
|
39
121
|
/** The singular noun the tools are named with: `issue` gives `create_issue`. */
|
|
40
122
|
label: z.string().optional(),
|
|
123
|
+
/**
|
|
124
|
+
* Fields defined at runtime as records of another collection (P5): `fields`
|
|
125
|
+
* names it, and `table`, when given, the `string` field on both naming
|
|
126
|
+
* which table a row or a definition belongs to.
|
|
127
|
+
*/
|
|
128
|
+
openSchema: z
|
|
129
|
+
.object({
|
|
130
|
+
fields: z.string().min(1).max(FIELD_LIMITS.nameChars),
|
|
131
|
+
table: z.string().min(1).max(FIELD_LIMITS.nameChars).optional(),
|
|
132
|
+
})
|
|
133
|
+
.strict()
|
|
134
|
+
.optional(),
|
|
135
|
+
/**
|
|
136
|
+
* A visitor on one of the app's public pages may `get` and `list` every
|
|
137
|
+
* record of this collection in that instance (P3).
|
|
138
|
+
*/
|
|
139
|
+
publicRead: z.boolean().optional(),
|
|
140
|
+
/**
|
|
141
|
+
* A visitor on one of the app's public pages may create records here, and
|
|
142
|
+
* nothing else: no update, remove or batch, and no get or list unless
|
|
143
|
+
* `publicRead` is set too (P3).
|
|
144
|
+
*/
|
|
145
|
+
publicSubmit: z.boolean().optional(),
|
|
146
|
+
/**
|
|
147
|
+
* Brydio emails the visitor once after a public submission here (FO04):
|
|
148
|
+
* `field` names a `string` field holding their address; `subject` and
|
|
149
|
+
* `message` are short plain text; `link` adds a button back to the page.
|
|
150
|
+
* Needs `publicSubmit`. Brydio decides when, how often and from whom.
|
|
151
|
+
*/
|
|
152
|
+
confirmEmail: confirmEmailSchema.optional(),
|
|
153
|
+
/**
|
|
154
|
+
* Answers nobody can tie to who gave them (P13): `group` names a
|
|
155
|
+
* structured field (a number, choice, date, boolean, token or project)
|
|
156
|
+
* the answers are read by, a whole group at a time and only once it holds
|
|
157
|
+
* `minimum` answers (at least 5, the default). Brydio keeps no writer,
|
|
158
|
+
* cuts times to the day, tells no live change, and refuses an update, a
|
|
159
|
+
* batch, a watch and a create naming its own writer.
|
|
160
|
+
*/
|
|
161
|
+
anonymous: anonymousSchema.optional(),
|
|
162
|
+
/** One of its string[] fields: the user ids who alone may read a record, when it names any (P16). */
|
|
163
|
+
readers: readersSchema.optional(),
|
|
164
|
+
/** One of its string[] fields: the user ids who alone may change a record, when it names any (DW06). */
|
|
165
|
+
editors: editorsSchema.optional(),
|
|
41
166
|
});
|
|
42
|
-
const screenSchema = z
|
|
167
|
+
const screenSchema = z
|
|
168
|
+
.object({
|
|
43
169
|
/**
|
|
44
170
|
* Relative to the bundle's root: `screens/board.js` or `screens/board.mjs`.
|
|
45
171
|
* The server's schema said `.js` only while its store and the mount took
|
|
46
|
-
* `.mjs` too; the schema gives (E1 and Hodler, 16 Sep 14:04 and 14:12).
|
|
172
|
+
* `.mjs` too; the schema gives (E1 and Hodler, 16 Sep 14:04 and 14:12). A
|
|
173
|
+
* frame screen's entry is its page, `screens/settings.html` (PB27).
|
|
47
174
|
*/
|
|
48
175
|
entry: z
|
|
49
176
|
.string()
|
|
50
177
|
.min(1)
|
|
51
178
|
.max(200)
|
|
52
|
-
.regex(/^(?!\/)(?!.*\.\.)[A-Za-z0-9_\-./]+\.m?js$/, 'An entry is a .js or .mjs path inside the bundle.'),
|
|
179
|
+
.regex(/^(?!\/)(?!.*\.\.)[A-Za-z0-9_\-./]+\.(?:m?js|html)$/, 'An entry is a .js or .mjs path inside the bundle, or an .html page for a frame screen.'),
|
|
180
|
+
/** `tree` (the default): drawn by Brydio from the app's elements. `frame`: the app's own HTML and CSS (PB27). */
|
|
181
|
+
kind: z.enum(SCREEN_KINDS).optional(),
|
|
182
|
+
})
|
|
183
|
+
.superRefine((screen, ctx) => {
|
|
184
|
+
const page = screen.entry.endsWith('.html');
|
|
185
|
+
if ((screen.kind === 'frame') !== page) {
|
|
186
|
+
ctx.addIssue({
|
|
187
|
+
code: 'custom',
|
|
188
|
+
path: ['entry'],
|
|
189
|
+
message: screen.kind === 'frame'
|
|
190
|
+
? 'A frame screen’s entry is an .html page inside the bundle.'
|
|
191
|
+
: 'An .html entry is a frame screen: give the screen "kind": "frame".',
|
|
192
|
+
params: { code: 'screen_kind_entry' },
|
|
193
|
+
});
|
|
194
|
+
}
|
|
53
195
|
});
|
|
54
196
|
/** Most custom tools one app may declare (A3-F08). */
|
|
55
|
-
export const MAX_CUSTOM_TOOLS =
|
|
197
|
+
export const MAX_CUSTOM_TOOLS = 100;
|
|
56
198
|
/** A tool name the model and a policy row can both use as it is. */
|
|
57
199
|
export const CUSTOM_TOOL_NAME = /^[a-z][a-z0-9_]{0,59}$/;
|
|
58
200
|
/**
|
|
@@ -74,23 +216,67 @@ const customToolSchema = z.object({
|
|
|
74
216
|
write: z.boolean().optional(),
|
|
75
217
|
/** The collection it works on, when it works on one: its grant then needs that collection too. */
|
|
76
218
|
collection: z.string().optional(),
|
|
219
|
+
/**
|
|
220
|
+
* Callable from a public page (P3). Its handler then runs for a visitor,
|
|
221
|
+
* whose `data` is held to `publicRead` and `publicSubmit` and who can use
|
|
222
|
+
* nothing else of the workspace's.
|
|
223
|
+
*/
|
|
224
|
+
public: z.boolean().optional(),
|
|
77
225
|
});
|
|
78
226
|
const toolsSchema = z.object({
|
|
79
227
|
/** Off only when the app supplies every tool itself (A3-F08). */
|
|
80
|
-
|
|
228
|
+
/**
|
|
229
|
+
* Off only when the app supplies every tool itself. `"read"` keeps the
|
|
230
|
+
* generated reads (get, list, search) and leaves every write to the app's
|
|
231
|
+
* own tools, where its rules about who may change what live (DW06).
|
|
232
|
+
*/
|
|
233
|
+
generated: z.union([z.boolean(), z.literal('read')]).optional(),
|
|
81
234
|
custom: z.array(customToolSchema).max(MAX_CUSTOM_TOOLS).optional(),
|
|
82
235
|
});
|
|
236
|
+
/** Most secrets one app may declare (ADR-A24). */
|
|
237
|
+
export const MAX_APP_SECRETS = 20;
|
|
238
|
+
/** The longest value one secret may hold, in characters. */
|
|
239
|
+
export const MAX_SECRET_CHARS = 8 * 1024;
|
|
240
|
+
/** A secret's name, as a handler asks for it: `api_key`. */
|
|
241
|
+
export const SECRET_NAME = /^[a-z][a-z0-9_]{0,59}$/;
|
|
242
|
+
/**
|
|
243
|
+
* A secret the app needs (ADR-A24): named here, never valued here. The value
|
|
244
|
+
* is entered in the app's settings by an administrator, or stored by the
|
|
245
|
+
* app's own handler with `secrets.set`, and only that app's handlers can read
|
|
246
|
+
* it, with `secrets.get`. A screen never can.
|
|
247
|
+
*
|
|
248
|
+
* `install` (the default) is one value for the whole install; `instance` is
|
|
249
|
+
* one per instance.
|
|
250
|
+
*/
|
|
251
|
+
const secretSchema = z.object({
|
|
252
|
+
name: z.string().regex(SECRET_NAME, 'A secret name is lower case letters, digits and underscores, starting with a letter.'),
|
|
253
|
+
label: z.string().min(1).max(60),
|
|
254
|
+
description: z.string().max(300).optional(),
|
|
255
|
+
required: z.boolean().optional(),
|
|
256
|
+
scope: z.enum(['install', 'instance']).optional(),
|
|
257
|
+
});
|
|
83
258
|
const grantsSchema = z.object({
|
|
84
259
|
tools: z.array(z.string().max(100)).max(200).optional(),
|
|
85
260
|
collections: z.array(z.string().max(100)).max(FIELD_LIMITS.collections + 1).optional(),
|
|
86
261
|
host: z.array(z.string().max(60)).max(20).optional(),
|
|
87
262
|
});
|
|
263
|
+
/** The `instances` rules an app may declare (CA10). */
|
|
264
|
+
export const INSTANCE_RULES = ['one-per-workspace'];
|
|
88
265
|
const extensionShape = {
|
|
89
266
|
placements: z.array(placementSchema).max(20).optional(),
|
|
90
267
|
data: z.record(z.string(), collectionSchema).optional(),
|
|
91
268
|
tools: toolsSchema.optional(),
|
|
92
269
|
screens: z.record(z.string(), screenSchema).optional(),
|
|
93
270
|
grants: grantsSchema.optional(),
|
|
271
|
+
/** The secrets its handlers read (ADR-A24): names only, never values. */
|
|
272
|
+
secrets: z.array(secretSchema).max(MAX_APP_SECRETS).optional(),
|
|
273
|
+
/**
|
|
274
|
+
* How many instances a workspace may keep (CA10). `"one-per-workspace"`:
|
|
275
|
+
* a single workspace instance, shown in the sidebar and on any project as
|
|
276
|
+
* a view of that project's records; never a project's or a person's own.
|
|
277
|
+
* Left out, as many as people make.
|
|
278
|
+
*/
|
|
279
|
+
instances: z.enum(INSTANCE_RULES).optional(),
|
|
94
280
|
/**
|
|
95
281
|
* How records move when the schema changes between versions (A3-F07).
|
|
96
282
|
* Checked against the previous version when a version is published, and
|
|
@@ -103,14 +289,14 @@ const extensionShape = {
|
|
|
103
289
|
* still loads; `POST /apps/publish` requires it and checks it (A5-F04-S03).
|
|
104
290
|
*/
|
|
105
291
|
sdk: z.string().max(MANIFEST_LIMITS.versionChars).regex(SEMVER_FORMAT).optional(),
|
|
292
|
+
/**
|
|
293
|
+
* The app's own colours and shape (PB21): an accent, named colours each
|
|
294
|
+
* with a light and a dark value, a rounding and a density. No font: every
|
|
295
|
+
* app uses Brydio's. Checked by `themeProblems` (`look.ts`), Brydio's one
|
|
296
|
+
* rule set, copied.
|
|
297
|
+
*/
|
|
298
|
+
theme: z.unknown().optional(),
|
|
106
299
|
};
|
|
107
|
-
/**
|
|
108
|
-
* Everything wrong with an app's additions that a type cannot say.
|
|
109
|
-
*
|
|
110
|
-
* Exported on its own so E5's validator can fold the codes into its import
|
|
111
|
-
* report; the zod schemas below run it too, so a parse never accepts what
|
|
112
|
-
* this refuses.
|
|
113
|
-
*/
|
|
114
300
|
export function dataProblems(additions, options = {}) {
|
|
115
301
|
const problems = [];
|
|
116
302
|
const collections = Object.entries(additions.data ?? {});
|
|
@@ -225,6 +411,38 @@ export function dataProblems(additions, options = {}) {
|
|
|
225
411
|
}
|
|
226
412
|
}
|
|
227
413
|
}
|
|
414
|
+
// Open-schema collections (P5): the companion is declared, shaped as the
|
|
415
|
+
// host reads it, and defines one collection's fields at most.
|
|
416
|
+
const companions = new Map();
|
|
417
|
+
for (const [collection, declared] of collections) {
|
|
418
|
+
if (!declared.openSchema)
|
|
419
|
+
continue;
|
|
420
|
+
for (const problem of openSchemaProblems(collection, declared.openSchema, additions.data ?? {})) {
|
|
421
|
+
problems.push({ ...problem, collection });
|
|
422
|
+
}
|
|
423
|
+
const other = companions.get(declared.openSchema.fields);
|
|
424
|
+
if (other) {
|
|
425
|
+
problems.push({
|
|
426
|
+
code: 'data_open_fields_unknown',
|
|
427
|
+
collection,
|
|
428
|
+
message: `${declared.openSchema.fields} already defines ${other}'s fields; give ${collection} its own.`,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
companions.set(declared.openSchema.fields, collection);
|
|
432
|
+
}
|
|
433
|
+
// A confirmation email names a string field of a collection visitors submit to (FO04).
|
|
434
|
+
for (const [collection, declared] of collections) {
|
|
435
|
+
problems.push(...confirmEmailProblems(collection, declared));
|
|
436
|
+
}
|
|
437
|
+
// An anonymous collection groups its answers by one of its structured fields, names nobody (P13).
|
|
438
|
+
for (const [collection, declared] of collections) {
|
|
439
|
+
problems.push(...anonymousProblems(collection, declared));
|
|
440
|
+
}
|
|
441
|
+
// A collection whose records name their readers names a list of user ids to keep them in (P16).
|
|
442
|
+
for (const [collection, declared] of collections) {
|
|
443
|
+
problems.push(...readersProblems(collection, declared));
|
|
444
|
+
problems.push(...editorsProblems(collection, declared));
|
|
445
|
+
}
|
|
228
446
|
// What the app keeps must be what it asks to keep (A3-F06-S01): a
|
|
229
447
|
// collection the grants leave out would be data a workspace never agreed to
|
|
230
448
|
// hold, found only when the first write is refused.
|
|
@@ -241,14 +459,141 @@ export function dataProblems(additions, options = {}) {
|
|
|
241
459
|
}
|
|
242
460
|
}
|
|
243
461
|
problems.push(...customToolProblems(additions, options));
|
|
462
|
+
problems.push(...secretProblems(additions, options));
|
|
463
|
+
problems.push(...instanceRuleProblems(additions));
|
|
244
464
|
const screens = new Set(Object.keys(additions.screens ?? {}));
|
|
465
|
+
const placementKeys = new Set();
|
|
245
466
|
for (const placement of additions.placements ?? []) {
|
|
467
|
+
const key = effectivePlacementKey(placement);
|
|
468
|
+
if (placementKeys.has(key)) {
|
|
469
|
+
problems.push({
|
|
470
|
+
code: 'placement_key_taken',
|
|
471
|
+
message: `Two placements use the key "${key}"; each independently addable placement needs its own key.`,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
placementKeys.add(key);
|
|
476
|
+
}
|
|
246
477
|
if (!screens.has(placement.screen)) {
|
|
247
478
|
problems.push({
|
|
248
479
|
code: 'placement_screen_unknown',
|
|
249
480
|
message: `A ${placement.kind} placement opens "${placement.screen}", which is not one of the app's screens.`,
|
|
250
481
|
});
|
|
251
482
|
}
|
|
483
|
+
// A card must say how big it can be drawn (CC11-F10-S01).
|
|
484
|
+
if (placement.kind === 'home' && !placement.sizes?.length) {
|
|
485
|
+
problems.push({
|
|
486
|
+
code: 'placement_home_sizes',
|
|
487
|
+
message: `The Home card "${placement.screen}" must say which sizes it draws at: small, medium or large.`,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
if (placement.kind === 'project-widget' && !placement.sizes?.length) {
|
|
491
|
+
problems.push({
|
|
492
|
+
code: 'placement_widget_sizes',
|
|
493
|
+
message: `The project widget "${placement.screen}" must say which sizes it draws at: small, medium or large.`,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
if (!SIZED_PLACEMENT_KINDS.includes(placement.kind) && placement.sizes?.length) {
|
|
497
|
+
problems.push({
|
|
498
|
+
code: 'placement_sizes_not_home',
|
|
499
|
+
message: `Only a Home card or a project widget has sizes; the ${placement.kind} placement "${placement.screen}" cannot.`,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
if (options.retired && placement.kind === 'project-tab') {
|
|
503
|
+
problems.push({
|
|
504
|
+
code: 'placement_tab_retired',
|
|
505
|
+
message: 'Project tabs are retired; declare a project-widget with sizes instead.',
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
// A public page is one screen at its own address: nothing to list in a
|
|
509
|
+
// sidebar, and nothing for an admin to fill in when it is placed.
|
|
510
|
+
if (placement.kind === 'public-page' && (placement.children || placement.settings)) {
|
|
511
|
+
problems.push({
|
|
512
|
+
code: 'placement_public_shape',
|
|
513
|
+
message: `The public page "${placement.screen}" can have no children or settings: it is one screen at its own address.`,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
// A chat card is drawn at the message's own size, in a message nobody
|
|
517
|
+
// places by hand (FO03): nothing to size, configure or list.
|
|
518
|
+
if (placement.kind === 'chat-card' && (placement.sizes?.length || placement.children || placement.settings)) {
|
|
519
|
+
problems.push({
|
|
520
|
+
code: 'placement_chat_card_shape',
|
|
521
|
+
message: `The chat card "${placement.screen}" cannot have sizes, children or settings.`,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
// A profile panel is drawn on every profile the viewer opens: nothing to size, configure or list.
|
|
525
|
+
if (placement.kind === 'person-profile' && (placement.sizes?.length || placement.children || placement.settings)) {
|
|
526
|
+
problems.push({
|
|
527
|
+
code: 'placement_person_profile_shape',
|
|
528
|
+
message: `The profile panel "${placement.screen}" cannot have sizes, children or settings.`,
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
// A public page with nothing public could show a visitor nothing at all.
|
|
533
|
+
if ((additions.placements ?? []).some(placement => placement.kind === 'public-page')) {
|
|
534
|
+
const publicData = Object.values(additions.data ?? {}).some(declared => declared.publicRead || declared.publicSubmit);
|
|
535
|
+
const publicTool = (additions.tools?.custom ?? []).some(tool => tool.public);
|
|
536
|
+
if (!publicData && !publicTool) {
|
|
537
|
+
problems.push({
|
|
538
|
+
code: 'placement_public_nothing',
|
|
539
|
+
message: 'A public page needs something a visitor may use: mark a collection publicRead or publicSubmit, or a custom tool public.',
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
problems.push(...folderProblems(additions));
|
|
544
|
+
return problems;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* A folder that nests or creates (ADR-A21) must stay within
|
|
548
|
+
* `MAX_FOLDER_DEPTH`, and each "New …" must name a tool the app has that
|
|
549
|
+
* changes records. Only the new keys are checked: a published manifest's
|
|
550
|
+
* existing folder still reads as it did.
|
|
551
|
+
*/
|
|
552
|
+
function folderProblems(additions) {
|
|
553
|
+
const problems = [];
|
|
554
|
+
const custom = new Map((additions.tools?.custom ?? []).map(tool => [tool.name, tool]));
|
|
555
|
+
const generatedWrites = additions.tools?.generated === false || additions.tools?.generated === 'read'
|
|
556
|
+
? new Set()
|
|
557
|
+
: new Set(Object.entries(additions.data ?? {}).flatMap(([name, declared]) => {
|
|
558
|
+
const label = labelOf(name, declared.label);
|
|
559
|
+
return [`create_${label}`, `update_${label}`, `delete_${label}`, `batch_${label}s`];
|
|
560
|
+
}));
|
|
561
|
+
const generatedReads = additions.tools?.generated === false
|
|
562
|
+
? new Set()
|
|
563
|
+
: new Set(Object.entries(additions.data ?? {}).flatMap(([name, declared]) => {
|
|
564
|
+
const label = labelOf(name, declared.label);
|
|
565
|
+
return [`get_${label}`, `list_${label}s`, `search_${label}s`];
|
|
566
|
+
}));
|
|
567
|
+
for (const placement of additions.placements ?? []) {
|
|
568
|
+
const children = placement.children;
|
|
569
|
+
if (!children)
|
|
570
|
+
continue;
|
|
571
|
+
const depth = 1 + (children.nested?.length ?? 0);
|
|
572
|
+
if (depth > MAX_FOLDER_DEPTH) {
|
|
573
|
+
problems.push({
|
|
574
|
+
code: 'placement_children_too_deep',
|
|
575
|
+
message: `The "${placement.screen}" folder nests ${depth} levels deep; a sidebar folder may nest at most ${MAX_FOLDER_DEPTH}.`,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
const creates = [children.create, ...(children.nested ?? []).map(level => level.create)];
|
|
579
|
+
for (const create of creates) {
|
|
580
|
+
if (!create)
|
|
581
|
+
continue;
|
|
582
|
+
const own = custom.get(create.tool);
|
|
583
|
+
const reads = own ? own.write !== true : generatedReads.has(create.tool);
|
|
584
|
+
if (!own && !reads && !generatedWrites.has(create.tool)) {
|
|
585
|
+
problems.push({
|
|
586
|
+
code: 'placement_create_tool_unknown',
|
|
587
|
+
message: `The "${placement.screen}" folder creates with ${create.tool}, which is not one of the app's tools.`,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
else if (reads) {
|
|
591
|
+
problems.push({
|
|
592
|
+
code: 'placement_create_not_write',
|
|
593
|
+
message: `The "${placement.screen}" folder creates with ${create.tool}, which only reads: name a tool that makes records, or mark a custom one write: true.`,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
}
|
|
252
597
|
}
|
|
253
598
|
return problems;
|
|
254
599
|
}
|
|
@@ -268,18 +613,28 @@ function customToolProblems(additions, options) {
|
|
|
268
613
|
: new Set(Object.entries(additions.data ?? {}).flatMap(([name, declared]) => {
|
|
269
614
|
const label = labelOf(name, declared.label);
|
|
270
615
|
const plural = `${label}s`;
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
`delete_${label}`,
|
|
276
|
-
`list_${plural}`,
|
|
277
|
-
`search_${plural}`,
|
|
278
|
-
`batch_${plural}`,
|
|
279
|
-
];
|
|
616
|
+
const reads = [`get_${label}`, `list_${plural}`, `search_${plural}`];
|
|
617
|
+
// With only reads generated, the write names are the app's to use.
|
|
618
|
+
return additions.tools?.generated === 'read'
|
|
619
|
+
? reads
|
|
620
|
+
: [...reads, `create_${label}`, `update_${label}`, `delete_${label}`, `batch_${plural}`];
|
|
280
621
|
}));
|
|
281
622
|
const seen = new Set();
|
|
282
623
|
const granted = additions.grants?.tools ?? [];
|
|
624
|
+
// Another app's tool, asked for by its assistant name `<slug>__<tool>`
|
|
625
|
+
// (FO07): never an unknown custom tool of this app, but naming the app
|
|
626
|
+
// itself would be a way round its own grants.
|
|
627
|
+
const name = additions.name;
|
|
628
|
+
const own = typeof name === 'string' ? appToolPrefix(name) : null;
|
|
629
|
+
for (const grant of granted) {
|
|
630
|
+
const qualified = crossAppTool(grant);
|
|
631
|
+
if (qualified && own !== null && qualified.app === own) {
|
|
632
|
+
problems.push({
|
|
633
|
+
code: 'grant_tool_cross_app_self',
|
|
634
|
+
message: `${grant} names this app's own tool: ask for ${qualified.tool} instead.`,
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
}
|
|
283
638
|
for (const tool of custom) {
|
|
284
639
|
if (generated.has(tool.name) || seen.has(tool.name)) {
|
|
285
640
|
problems.push({
|
|
@@ -316,20 +671,89 @@ function customToolProblems(additions, options) {
|
|
|
316
671
|
}
|
|
317
672
|
return problems;
|
|
318
673
|
}
|
|
674
|
+
/**
|
|
675
|
+
* Another installed app's tool, as `grants.tools` names it (FO07): the name
|
|
676
|
+
* the assistant knows it by, `<slug>__<tool>`. Null for anything else.
|
|
677
|
+
* A handler calls it with `tools.call('<slug>__<tool>', input)`.
|
|
678
|
+
*/
|
|
679
|
+
export function crossAppTool(name) {
|
|
680
|
+
const at = name.indexOf('__');
|
|
681
|
+
if (at <= 0 || at + 2 >= name.length)
|
|
682
|
+
return null;
|
|
683
|
+
return { app: name.slice(0, at), tool: name.slice(at + 2) };
|
|
684
|
+
}
|
|
685
|
+
/** An app's name as the front of its tools' assistant names: `issue-tracker` → `issue_tracker`. */
|
|
686
|
+
export const appToolPrefix = (name) => name
|
|
687
|
+
.replace(/[^A-Za-z0-9_]+/g, '_')
|
|
688
|
+
.replace(/_+/g, '_')
|
|
689
|
+
.replace(/^_+|_+$/g, '');
|
|
690
|
+
/**
|
|
691
|
+
* Each secret declared once, and asked for (ADR-A24): an app that declares
|
|
692
|
+
* secrets without the `secrets` host grant could never read one.
|
|
693
|
+
*/
|
|
694
|
+
/**
|
|
695
|
+
* One instance per workspace is added once, from the workspace sidebar
|
|
696
|
+
* (CA10): an app that says so and offers no sidebar item could never be
|
|
697
|
+
* added at all.
|
|
698
|
+
*/
|
|
699
|
+
function instanceRuleProblems(additions) {
|
|
700
|
+
if (additions.instances !== 'one-per-workspace')
|
|
701
|
+
return [];
|
|
702
|
+
if ((additions.placements ?? []).some(one => one.kind === 'workspace-sidebar'))
|
|
703
|
+
return [];
|
|
704
|
+
return [
|
|
705
|
+
{
|
|
706
|
+
code: 'instances_needs_sidebar',
|
|
707
|
+
message: 'An app kept once per workspace is added from the workspace sidebar: add a "workspace-sidebar" placement.',
|
|
708
|
+
},
|
|
709
|
+
];
|
|
710
|
+
}
|
|
711
|
+
function secretProblems(additions, options) {
|
|
712
|
+
const problems = [];
|
|
713
|
+
const secrets = additions.secrets ?? [];
|
|
714
|
+
const seen = new Set();
|
|
715
|
+
for (const secret of secrets) {
|
|
716
|
+
if (seen.has(secret.name)) {
|
|
717
|
+
problems.push({
|
|
718
|
+
code: 'secret_name_taken',
|
|
719
|
+
field: secret.name,
|
|
720
|
+
message: `The secret ${secret.name} is declared twice; keep one.`,
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
seen.add(secret.name);
|
|
724
|
+
}
|
|
725
|
+
const host = additions.grants?.host ?? [];
|
|
726
|
+
if (options.grants !== false && secrets.length && !host.includes('secrets') && !host.includes('*')) {
|
|
727
|
+
problems.push({
|
|
728
|
+
code: 'grant_secrets_missing',
|
|
729
|
+
message: 'The app declares secrets but does not ask to read them: add "secrets" to grants.host.',
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
return problems;
|
|
733
|
+
}
|
|
319
734
|
const refuse = (additions, ctx, options = {}) => {
|
|
735
|
+
for (const problem of themeProblems(additions.theme)) {
|
|
736
|
+
ctx.addIssue({ code: 'custom', message: problem.message, path: problem.path.split('.'), params: { code: problem.code } });
|
|
737
|
+
}
|
|
320
738
|
for (const problem of dataProblems(additions, options)) {
|
|
321
739
|
ctx.addIssue({
|
|
322
740
|
code: 'custom',
|
|
323
741
|
message: problem.message,
|
|
324
|
-
path: problem.code === 'grant_tool_missing'
|
|
742
|
+
path: problem.code === 'grant_tool_missing' || problem.code === 'grant_tool_cross_app_self'
|
|
325
743
|
? ['grants', 'tools']
|
|
326
|
-
: problem.code
|
|
327
|
-
? ['grants', '
|
|
328
|
-
: problem.code
|
|
329
|
-
? ['
|
|
330
|
-
: problem.
|
|
331
|
-
? ['
|
|
332
|
-
:
|
|
744
|
+
: problem.code === 'grant_secrets_missing'
|
|
745
|
+
? ['grants', 'host']
|
|
746
|
+
: problem.code === 'secret_name_taken'
|
|
747
|
+
? ['secrets']
|
|
748
|
+
: problem.code === 'instances_needs_sidebar'
|
|
749
|
+
? ['instances']
|
|
750
|
+
: problem.code.startsWith('grant_')
|
|
751
|
+
? ['grants', 'collections']
|
|
752
|
+
: problem.code.startsWith('custom_')
|
|
753
|
+
? ['tools', 'custom']
|
|
754
|
+
: problem.collection
|
|
755
|
+
? ['data', problem.collection, ...(problem.field ? ['schema', problem.field] : [])]
|
|
756
|
+
: [],
|
|
333
757
|
params: { code: problem.code },
|
|
334
758
|
});
|
|
335
759
|
}
|
|
@@ -344,6 +768,14 @@ export const manifestExtensionsSchema = z.object(extensionShape).superRefine((ad
|
|
|
344
768
|
export const storedExtensionsSchema = z.object(extensionShape).superRefine((additions, ctx) => refuse(additions, ctx, { grants: false }));
|
|
345
769
|
/** E5's manifest with the additions: the whole `.brydio/app.json` of an app. */
|
|
346
770
|
export const appManifestSchema = manifestSchema.extend(extensionShape).superRefine((additions, ctx) => refuse(additions, ctx));
|
|
771
|
+
/**
|
|
772
|
+
* A manifest being written now, as `brydio validate`, `build` and `dev` read
|
|
773
|
+
* it: the same, and nothing retired (`placement_tab_retired`) — which a
|
|
774
|
+
* published version may still hold.
|
|
775
|
+
*/
|
|
776
|
+
export const newAppManifestSchema = manifestSchema
|
|
777
|
+
.extend(extensionShape)
|
|
778
|
+
.superRefine((additions, ctx) => refuse(additions, ctx, { retired: true }));
|
|
347
779
|
/**
|
|
348
780
|
* The singular a collection's tools are named with: the manifest's, else the
|
|
349
781
|
* collection's name with a trailing "s" taken off (`issues` → `issue`).
|
|
@@ -360,8 +792,22 @@ export function labelOf(collection, label) {
|
|
|
360
792
|
*/
|
|
361
793
|
export function collectionsOf(manifest) {
|
|
362
794
|
const parsed = storedExtensionsSchema.parse({ data: manifest.data ?? {} });
|
|
795
|
+
const owners = new Map(Object.entries(parsed.data ?? {}).flatMap(([name, declared]) => declared.openSchema ? [[declared.openSchema.fields, { name, flag: declared.openSchema }]] : []));
|
|
363
796
|
return Object.entries(parsed.data ?? {}).map(([name, declared]) => {
|
|
364
|
-
const
|
|
797
|
+
const owner = owners.get(name);
|
|
798
|
+
// The field naming a row's table is kept in plain on both sides, so a
|
|
799
|
+
// table is filtered and counted without decrypting a row (P5).
|
|
800
|
+
const tableField = declared.openSchema?.table ?? owner?.flag.table;
|
|
801
|
+
const fields = Object.fromEntries(Object.entries(declared.schema).map(([field, raw]) => {
|
|
802
|
+
const type = parseFieldType(raw);
|
|
803
|
+
if (field === tableField)
|
|
804
|
+
return [field, { ...type, plain: true }];
|
|
805
|
+
// A record's readers or editors may name a whole team (P16, DW06).
|
|
806
|
+
return [
|
|
807
|
+
field,
|
|
808
|
+
(field === declared.readers || field === declared.editors) && type.kind === 'string[]' ? { ...type, maxEntries: READERS_LIMIT } : type,
|
|
809
|
+
];
|
|
810
|
+
}));
|
|
365
811
|
const label = labelOf(name, declared.label);
|
|
366
812
|
const names = Object.keys(fields);
|
|
367
813
|
return {
|
|
@@ -373,6 +819,13 @@ export function collectionsOf(manifest) {
|
|
|
373
819
|
sortable: names.filter(field => isSortable(fields[field])),
|
|
374
820
|
search: declared.search ?? [],
|
|
375
821
|
projectField: names.find(field => fields[field].kind === 'project') ?? null,
|
|
822
|
+
...(declared.openSchema
|
|
823
|
+
? { openSchema: { fields: declared.openSchema.fields, table: declared.openSchema.table ?? null } }
|
|
824
|
+
: {}),
|
|
825
|
+
...(owner ? { definesFieldsOf: owner.name } : {}),
|
|
826
|
+
...(declared.anonymous ? { anonymous: anonymousSpec(declared.anonymous) } : {}),
|
|
827
|
+
...(declared.readers ? { readers: declared.readers } : {}),
|
|
828
|
+
...(declared.editors ? { editors: declared.editors } : {}),
|
|
376
829
|
};
|
|
377
830
|
});
|
|
378
831
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two kinds of screen (`tasks/app-publishing` PB27). A copy of Brydio's
|
|
3
|
+
* `apps/api/src/apps/manifest/screen-kinds.ts`.
|
|
4
|
+
*
|
|
5
|
+
* A **tree screen** is the app's elements, drawn by Brydio (PB21 to PB24). A
|
|
6
|
+
* **frame screen** is the app's own HTML and CSS, drawn in a sandboxed frame
|
|
7
|
+
* on an origin that holds only app code. The manifest says which, per screen:
|
|
8
|
+
* `{ "kind": "frame", "entry": "screens/settings.html" }`. Left out, a screen
|
|
9
|
+
* is a tree screen, so every manifest from before keeps its meaning.
|
|
10
|
+
*/
|
|
11
|
+
export declare const SCREEN_KINDS: readonly ["tree", "frame"];
|
|
12
|
+
export type ScreenKind = (typeof SCREEN_KINDS)[number];
|
|
13
|
+
/** Which kind one of the manifest's screens is; `tree` when it says nothing. */
|
|
14
|
+
export declare function screenKindOf(manifest: unknown, screen: string): ScreenKind;
|
|
15
|
+
/** The manifest's screens of one kind, by name. */
|
|
16
|
+
export declare function screensOfKind(manifest: unknown, kind: ScreenKind): string[];
|
|
17
|
+
/** Every frame screen's page, by its path inside the bundle. */
|
|
18
|
+
export declare function framePagesOf(manifest: unknown): Set<string>;
|