@brydio/manifest 0.1.0-alpha.3 → 0.1.0-alpha.32

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/src/schema.js CHANGED
@@ -18,11 +18,81 @@ export { COLOUR_TOKENS, FIELD_LIMITS, parseFieldType, RESERVED_FIELDS, structure
18
18
  * screen nobody declared — are `dataProblems` below, with a code each, so an
19
19
  * import report can say which collection and which field, not just "invalid".
20
20
  */
21
+ const placementSettingSchema = z.object({
22
+ type: z.enum(['string', 'url']),
23
+ label: z.string().min(1).max(60),
24
+ required: z.boolean().optional(),
25
+ placeholder: z.string().max(200).optional(),
26
+ });
27
+ /**
28
+ * How deep a folder's rows may nest, counting its own rows as the first
29
+ * level (ADR-A21): Issues → sprints → issues is two. Each level is listed
30
+ * only when a person opens its parent, so depth costs nothing while closed;
31
+ * the limit is for the sidebar's width and a person's patience.
32
+ */
33
+ export const MAX_FOLDER_DEPTH = 4;
34
+ /**
35
+ * "New sprint" on a folder's level (ADR-A21): one of the app's tools, run
36
+ * through the screen's door with the name the person typed in `titleField`
37
+ * and, below the first level, the row it is created under in `parentField`.
38
+ */
39
+ const folderCreateSchema = z.object({
40
+ tool: z.string().min(1).max(64),
41
+ /** "sprint", in "New sprint". */
42
+ noun: z.string().min(1).max(40).optional(),
43
+ titleField: z.string().regex(FIELD_NAME, 'A field name is letters, digits and _, starting with a lower-case letter.').optional(),
44
+ parentField: z.string().regex(FIELD_NAME, 'A field name is letters, digits and _, starting with a lower-case letter.').optional(),
45
+ });
46
+ /** A level below a folder's own rows: what they are called, and how one is made. */
47
+ const folderLevelSchema = z.object({
48
+ noun: z.string().min(1).max(60).optional(),
49
+ create: folderCreateSchema.optional(),
50
+ });
51
+ /** The sizes a Home card or a project widget can be drawn at (CC11-F10). */
52
+ export const HOME_CARD_SIZES = ['small', 'medium', 'large'];
53
+ /** The kinds drawn as a card, which say the sizes they draw well at. */
54
+ export const SIZED_PLACEMENT_KINDS = ['home', 'project-widget'];
55
+ /** A stable identity for one independently addable placement offering. */
56
+ export const PLACEMENT_KEY = /^[a-z][a-z0-9-]{0,39}$/;
57
+ export function effectivePlacementKey(one) {
58
+ return one.key ?? `${one.kind}:${one.screen}`;
59
+ }
21
60
  const placementSchema = z.object({
22
- kind: z.enum(['project-tab', 'project-sidebar', 'workspace-sidebar']),
61
+ key: z.string().regex(PLACEMENT_KEY).optional(),
62
+ /**
63
+ * `home`: a card on a person's Home, drawn by `screen` at one of `sizes`.
64
+ * `project-widget`: a card on a project's page, drawn at one of `sizes`.
65
+ * `project-tab` is retired (30 Sep 2026): still read from a published
66
+ * manifest, refused in a new one (`placement_tab_retired`).
67
+ */
68
+ kind: z.enum(['project-tab', 'project-widget', 'project-sidebar', 'workspace-sidebar', 'home']),
23
69
  screen: z.string().min(1).max(FIELD_LIMITS.nameChars),
70
+ /** For `home` and `project-widget` only, and required there: the sizes the screen draws well at. */
71
+ sizes: z.array(z.enum(HOME_CARD_SIZES)).max(3).optional(),
24
72
  label: z.string().min(1).max(60).optional(),
25
73
  icon: z.string().max(60).optional(),
74
+ settings: z.record(z.string().regex(FIELD_NAME), placementSettingSchema).optional(),
75
+ /**
76
+ * A folder (A2-F02-S02): a sidebar item whose rows one of the app's read
77
+ * tools lists live, only while it is open. It can nest, and offer "New …"
78
+ * at each level, when the app opts in (ADR-A21).
79
+ */
80
+ children: z
81
+ .object({
82
+ tool: z.string().min(1).max(64),
83
+ refreshSeconds: z.number().int().min(15).max(3600).optional(),
84
+ cap: z.number().int().min(1).max(50).optional(),
85
+ noun: z.string().min(1).max(60).optional(),
86
+ /** A "New …" row at the folder's first level (ADR-A21). */
87
+ create: folderCreateSchema.optional(),
88
+ /**
89
+ * The levels under the first, in order (ADR-A21). A row the tool marks
90
+ * `hasChildren` opens only when a level is declared for it; without
91
+ * `nested` the folder stays one flat list.
92
+ */
93
+ nested: z.array(folderLevelSchema).max(8).optional(),
94
+ })
95
+ .optional(),
26
96
  });
27
97
  const collectionSchema = z.object({
28
98
  /** Field name to type, in the manifest's spelling (`"member?"`, `["todo","done"]`). */
@@ -73,6 +143,28 @@ const toolsSchema = z.object({
73
143
  generated: z.boolean().optional(),
74
144
  custom: z.array(customToolSchema).max(MAX_CUSTOM_TOOLS).optional(),
75
145
  });
146
+ /** Most secrets one app may declare (ADR-A24). */
147
+ export const MAX_APP_SECRETS = 20;
148
+ /** The longest value one secret may hold, in characters. */
149
+ export const MAX_SECRET_CHARS = 8 * 1024;
150
+ /** A secret's name, as a handler asks for it: `api_key`. */
151
+ export const SECRET_NAME = /^[a-z][a-z0-9_]{0,59}$/;
152
+ /**
153
+ * A secret the app needs (ADR-A24): named here, never valued here. The value
154
+ * is entered in the app's settings by an administrator, or stored by the
155
+ * app's own handler with `secrets.set`, and only that app's handlers can read
156
+ * it, with `secrets.get`. A screen never can.
157
+ *
158
+ * `install` (the default) is one value for the whole install; `instance` is
159
+ * one per instance.
160
+ */
161
+ const secretSchema = z.object({
162
+ name: z.string().regex(SECRET_NAME, 'A secret name is lower case letters, digits and underscores, starting with a letter.'),
163
+ label: z.string().min(1).max(60),
164
+ description: z.string().max(300).optional(),
165
+ required: z.boolean().optional(),
166
+ scope: z.enum(['install', 'instance']).optional(),
167
+ });
76
168
  const grantsSchema = z.object({
77
169
  tools: z.array(z.string().max(100)).max(200).optional(),
78
170
  collections: z.array(z.string().max(100)).max(FIELD_LIMITS.collections + 1).optional(),
@@ -84,6 +176,8 @@ const extensionShape = {
84
176
  tools: toolsSchema.optional(),
85
177
  screens: z.record(z.string(), screenSchema).optional(),
86
178
  grants: grantsSchema.optional(),
179
+ /** The secrets its handlers read (ADR-A24): names only, never values. */
180
+ secrets: z.array(secretSchema).max(MAX_APP_SECRETS).optional(),
87
181
  /**
88
182
  * How records move when the schema changes between versions (A3-F07).
89
183
  * Checked against the previous version when a version is published, and
@@ -97,13 +191,6 @@ const extensionShape = {
97
191
  */
98
192
  sdk: z.string().max(MANIFEST_LIMITS.versionChars).regex(SEMVER_FORMAT).optional(),
99
193
  };
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
194
  export function dataProblems(additions, options = {}) {
108
195
  const problems = [];
109
196
  const collections = Object.entries(additions.data ?? {});
@@ -234,14 +321,106 @@ export function dataProblems(additions, options = {}) {
234
321
  }
235
322
  }
236
323
  problems.push(...customToolProblems(additions, options));
324
+ problems.push(...secretProblems(additions, options));
237
325
  const screens = new Set(Object.keys(additions.screens ?? {}));
326
+ const placementKeys = new Set();
238
327
  for (const placement of additions.placements ?? []) {
328
+ const key = effectivePlacementKey(placement);
329
+ if (placementKeys.has(key)) {
330
+ problems.push({
331
+ code: 'placement_key_taken',
332
+ message: `Two placements use the key "${key}"; each independently addable placement needs its own key.`,
333
+ });
334
+ }
335
+ else {
336
+ placementKeys.add(key);
337
+ }
239
338
  if (!screens.has(placement.screen)) {
240
339
  problems.push({
241
340
  code: 'placement_screen_unknown',
242
341
  message: `A ${placement.kind} placement opens "${placement.screen}", which is not one of the app's screens.`,
243
342
  });
244
343
  }
344
+ // A card must say how big it can be drawn (CC11-F10-S01).
345
+ if (placement.kind === 'home' && !placement.sizes?.length) {
346
+ problems.push({
347
+ code: 'placement_home_sizes',
348
+ message: `The Home card "${placement.screen}" must say which sizes it draws at: small, medium or large.`,
349
+ });
350
+ }
351
+ if (placement.kind === 'project-widget' && !placement.sizes?.length) {
352
+ problems.push({
353
+ code: 'placement_widget_sizes',
354
+ message: `The project widget "${placement.screen}" must say which sizes it draws at: small, medium or large.`,
355
+ });
356
+ }
357
+ if (!SIZED_PLACEMENT_KINDS.includes(placement.kind) && placement.sizes?.length) {
358
+ problems.push({
359
+ code: 'placement_sizes_not_home',
360
+ message: `Only a Home card or a project widget has sizes; the ${placement.kind} placement "${placement.screen}" cannot.`,
361
+ });
362
+ }
363
+ if (options.retired && placement.kind === 'project-tab') {
364
+ problems.push({
365
+ code: 'placement_tab_retired',
366
+ message: 'Project tabs are retired; declare a project-widget with sizes instead.',
367
+ });
368
+ }
369
+ }
370
+ problems.push(...folderProblems(additions));
371
+ return problems;
372
+ }
373
+ /**
374
+ * A folder that nests or creates (ADR-A21) must stay within
375
+ * `MAX_FOLDER_DEPTH`, and each "New …" must name a tool the app has that
376
+ * changes records. Only the new keys are checked: a published manifest's
377
+ * existing folder still reads as it did.
378
+ */
379
+ function folderProblems(additions) {
380
+ const problems = [];
381
+ const custom = new Map((additions.tools?.custom ?? []).map(tool => [tool.name, tool]));
382
+ const generatedWrites = additions.tools?.generated === false
383
+ ? new Set()
384
+ : new Set(Object.entries(additions.data ?? {}).flatMap(([name, declared]) => {
385
+ const label = labelOf(name, declared.label);
386
+ return [`create_${label}`, `update_${label}`, `delete_${label}`, `batch_${label}s`];
387
+ }));
388
+ const generatedReads = additions.tools?.generated === false
389
+ ? new Set()
390
+ : new Set(Object.entries(additions.data ?? {}).flatMap(([name, declared]) => {
391
+ const label = labelOf(name, declared.label);
392
+ return [`get_${label}`, `list_${label}s`, `search_${label}s`];
393
+ }));
394
+ for (const placement of additions.placements ?? []) {
395
+ const children = placement.children;
396
+ if (!children)
397
+ continue;
398
+ const depth = 1 + (children.nested?.length ?? 0);
399
+ if (depth > MAX_FOLDER_DEPTH) {
400
+ problems.push({
401
+ code: 'placement_children_too_deep',
402
+ message: `The "${placement.screen}" folder nests ${depth} levels deep; a sidebar folder may nest at most ${MAX_FOLDER_DEPTH}.`,
403
+ });
404
+ }
405
+ const creates = [children.create, ...(children.nested ?? []).map(level => level.create)];
406
+ for (const create of creates) {
407
+ if (!create)
408
+ continue;
409
+ const own = custom.get(create.tool);
410
+ const reads = own ? own.write !== true : generatedReads.has(create.tool);
411
+ if (!own && !reads && !generatedWrites.has(create.tool)) {
412
+ problems.push({
413
+ code: 'placement_create_tool_unknown',
414
+ message: `The "${placement.screen}" folder creates with ${create.tool}, which is not one of the app's tools.`,
415
+ });
416
+ }
417
+ else if (reads) {
418
+ problems.push({
419
+ code: 'placement_create_not_write',
420
+ 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.`,
421
+ });
422
+ }
423
+ }
245
424
  }
246
425
  return problems;
247
426
  }
@@ -309,6 +488,33 @@ function customToolProblems(additions, options) {
309
488
  }
310
489
  return problems;
311
490
  }
491
+ /**
492
+ * Each secret declared once, and asked for (ADR-A24): an app that declares
493
+ * secrets without the `secrets` host grant could never read one.
494
+ */
495
+ function secretProblems(additions, options) {
496
+ const problems = [];
497
+ const secrets = additions.secrets ?? [];
498
+ const seen = new Set();
499
+ for (const secret of secrets) {
500
+ if (seen.has(secret.name)) {
501
+ problems.push({
502
+ code: 'secret_name_taken',
503
+ field: secret.name,
504
+ message: `The secret ${secret.name} is declared twice; keep one.`,
505
+ });
506
+ }
507
+ seen.add(secret.name);
508
+ }
509
+ const host = additions.grants?.host ?? [];
510
+ if (options.grants !== false && secrets.length && !host.includes('secrets') && !host.includes('*')) {
511
+ problems.push({
512
+ code: 'grant_secrets_missing',
513
+ message: 'The app declares secrets but does not ask to read them: add "secrets" to grants.host.',
514
+ });
515
+ }
516
+ return problems;
517
+ }
312
518
  const refuse = (additions, ctx, options = {}) => {
313
519
  for (const problem of dataProblems(additions, options)) {
314
520
  ctx.addIssue({
@@ -316,13 +522,17 @@ const refuse = (additions, ctx, options = {}) => {
316
522
  message: problem.message,
317
523
  path: problem.code === 'grant_tool_missing'
318
524
  ? ['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
- : [],
525
+ : problem.code === 'grant_secrets_missing'
526
+ ? ['grants', 'host']
527
+ : problem.code === 'secret_name_taken'
528
+ ? ['secrets']
529
+ : problem.code.startsWith('grant_')
530
+ ? ['grants', 'collections']
531
+ : problem.code.startsWith('custom_')
532
+ ? ['tools', 'custom']
533
+ : problem.collection
534
+ ? ['data', problem.collection, ...(problem.field ? ['schema', problem.field] : [])]
535
+ : [],
326
536
  params: { code: problem.code },
327
537
  });
328
538
  }
@@ -337,6 +547,14 @@ export const manifestExtensionsSchema = z.object(extensionShape).superRefine((ad
337
547
  export const storedExtensionsSchema = z.object(extensionShape).superRefine((additions, ctx) => refuse(additions, ctx, { grants: false }));
338
548
  /** E5's manifest with the additions: the whole `.brydio/app.json` of an app. */
339
549
  export const appManifestSchema = manifestSchema.extend(extensionShape).superRefine((additions, ctx) => refuse(additions, ctx));
550
+ /**
551
+ * A manifest being written now, as `brydio validate`, `build` and `dev` read
552
+ * it: the same, and nothing retired (`placement_tab_retired`) — which a
553
+ * published version may still hold.
554
+ */
555
+ export const newAppManifestSchema = manifestSchema
556
+ .extend(extensionShape)
557
+ .superRefine((additions, ctx) => refuse(additions, ctx, { retired: true }));
340
558
  /**
341
559
  * The singular a collection's tools are named with: the manifest's, else the
342
560
  * collection's name with a trailing "s" taken off (`issues` → `issue`).
package/src/secrets.d.ts CHANGED
@@ -30,3 +30,17 @@ export interface SecretFound {
30
30
  export declare function secretsInJson(document: unknown, at: string): SecretFound[];
31
31
  /** Every secret declared in a bundle's files, as the server finds them. */
32
32
  export declare function findSecrets(files: ReadonlyMap<string, Uint8Array>, root?: string): SecretFound[];
33
+ /**
34
+ * The server's sentence for `developer_key_in_bundle` (ADR-A22), word for
35
+ * word: `DEVELOPER_KEY_IN_BUNDLE` in Brydio's `app-publish.service.ts`.
36
+ */
37
+ export declare const DEVELOPER_KEY_MESSAGE = "That package contains a Brydio developer API key. A key belongs on your own server, never in an app: revoke it in Settings \u203A Developer, and call Brydio\u2019s model from a handler with the model grant instead.";
38
+ /** Whether a text holds a Brydio developer API key. */
39
+ export declare const holdsDeveloperKey: (text: string) => boolean;
40
+ /**
41
+ * The first file holding a developer API key, by path, as Brydio's publish
42
+ * route finds it (`developerKeyIn`). Unlike the declaration scan, every file
43
+ * is read, scripts included: the key's shape is exact enough that a match is
44
+ * a key, not a guess, and no part of an app may hold one (ADR-A24).
45
+ */
46
+ export declare function developerKeyIn(files: ReadonlyMap<string, Uint8Array>): string | null;
package/src/secrets.js CHANGED
@@ -35,21 +35,21 @@ const isRealValue = (value) => {
35
35
  return false;
36
36
  return true;
37
37
  };
38
- function walk(node, at, found) {
38
+ function walk(node, at, found, publicValue = () => false) {
39
39
  if (Array.isArray(node)) {
40
- node.forEach((one, index) => walk(one, `${at}[${index}]`, found));
40
+ node.forEach((one, index) => walk(one, `${at}[${index}]`, found, publicValue));
41
41
  return;
42
42
  }
43
43
  if (!node || typeof node !== 'object')
44
44
  return;
45
45
  for (const [key, value] of Object.entries(node)) {
46
46
  const path = at ? `${at}.${key}` : key;
47
- if (VALUE_KEYS.has(key) && isRealValue(value)) {
47
+ if (VALUE_KEYS.has(key) && isRealValue(value) && !publicValue(path)) {
48
48
  // Never quoted back: a report that repeated the secret would copy it somewhere else.
49
49
  found.push({ code: 'secret_in_bundle', message: SECRET_MESSAGE, path });
50
50
  continue;
51
51
  }
52
- walk(value, path, found);
52
+ walk(value, path, found, publicValue);
53
53
  }
54
54
  }
55
55
  /**
@@ -59,7 +59,7 @@ function walk(node, at, found) {
59
59
  */
60
60
  export function secretsInJson(document, at) {
61
61
  const found = [];
62
- walk(document, at, found);
62
+ walk(document, at, found, path => /(?:^|\.)placements\[\d+\]\.key$/.test(path));
63
63
  return found;
64
64
  }
65
65
  /** Every secret declared in a bundle's files, as the server finds them. */
@@ -79,3 +79,25 @@ export function findSecrets(files, root = '') {
79
79
  }
80
80
  return found;
81
81
  }
82
+ /**
83
+ * The server's sentence for `developer_key_in_bundle` (ADR-A22), word for
84
+ * word: `DEVELOPER_KEY_IN_BUNDLE` in Brydio's `app-publish.service.ts`.
85
+ */
86
+ export const DEVELOPER_KEY_MESSAGE = 'That package contains a Brydio developer API key. A key belongs on your own server, never in an app: revoke it in Settings › Developer, and call Brydio’s model from a handler with the model grant instead.';
87
+ /** A developer API key's exact shape, anywhere in a text. */
88
+ const DEVELOPER_KEY = /bry_live_[0-9a-f]{12}_[A-Za-z0-9_-]{43}/;
89
+ /** Whether a text holds a Brydio developer API key. */
90
+ export const holdsDeveloperKey = (text) => DEVELOPER_KEY.test(text);
91
+ /**
92
+ * The first file holding a developer API key, by path, as Brydio's publish
93
+ * route finds it (`developerKeyIn`). Unlike the declaration scan, every file
94
+ * is read, scripts included: the key's shape is exact enough that a match is
95
+ * a key, not a guess, and no part of an app may hold one (ADR-A24).
96
+ */
97
+ export function developerKeyIn(files) {
98
+ for (const [path, bytes] of files) {
99
+ if (holdsDeveloperKey(new TextDecoder('latin1').decode(bytes)))
100
+ return path;
101
+ }
102
+ return null;
103
+ }
package/src/validate.d.ts CHANGED
@@ -22,6 +22,12 @@ export interface ManifestValidation {
22
22
  manifest?: AppManifestWithData;
23
23
  problems: ManifestProblem[];
24
24
  }
25
- export declare function validateManifest(source: unknown): ManifestValidation;
25
+ export declare function validateManifest(source: unknown,
26
+ /** `retired`: a manifest being written now, which may not use a retired kind such as `project-tab`. */
27
+ options?: {
28
+ retired?: boolean;
29
+ }): ManifestValidation;
26
30
  /** Parses the text of `.brydio/app.json` and validates it. */
27
- export declare function validateManifestText(text: string): ManifestValidation;
31
+ export declare function validateManifestText(text: string, options?: {
32
+ retired?: boolean;
33
+ }): ManifestValidation;
package/src/validate.js CHANGED
@@ -1,6 +1,8 @@
1
- import { appManifestSchema } from "./schema.js";
2
- export function validateManifest(source) {
3
- const parsed = appManifestSchema.safeParse(source);
1
+ import { appManifestSchema, newAppManifestSchema } from "./schema.js";
2
+ export function validateManifest(source,
3
+ /** `retired`: a manifest being written now, which may not use a retired kind such as `project-tab`. */
4
+ options = {}) {
5
+ const parsed = (options.retired ? newAppManifestSchema : appManifestSchema).safeParse(source);
4
6
  if (parsed.success)
5
7
  return { ok: true, manifest: parsed.data, problems: [] };
6
8
  return {
@@ -14,7 +16,7 @@ export function validateManifest(source) {
14
16
  };
15
17
  }
16
18
  /** Parses the text of `.brydio/app.json` and validates it. */
17
- export function validateManifestText(text) {
19
+ export function validateManifestText(text, options = {}) {
18
20
  let json;
19
21
  try {
20
22
  json = JSON.parse(text);
@@ -25,5 +27,5 @@ export function validateManifestText(text) {
25
27
  problems: [{ code: 'manifest_not_json', message: `The manifest is not valid JSON: ${error instanceof Error ? error.message : String(error)}` }],
26
28
  };
27
29
  }
28
- return validateManifest(json);
30
+ return validateManifest(json, options);
29
31
  }