@brydio/manifest 0.1.0-alpha.4 → 0.1.0-alpha.41

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
@@ -2,6 +2,10 @@ 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
4
  import { migrationsSchema } from "./migrations.js";
5
+ import { openSchemaProblems } from "./open-schema.js";
6
+ import { confirmEmailProblems, confirmEmailSchema } from "./confirm-email.js";
7
+ import { anonymousProblems, anonymousSchema, anonymousSpec } from "./anonymous.js";
8
+ import { editorsProblems, editorsSchema, READERS_LIMIT, readersProblems, readersSchema } from "./readers.js";
5
9
  export { COLOUR_TOKENS, FIELD_LIMITS, parseFieldType, RESERVED_FIELDS, structuredFields, } from "./field-types.js";
6
10
  /**
7
11
  * What an app adds to `.brydio/app.json` to be more than a bundle of servers
@@ -18,11 +22,90 @@ export { COLOUR_TOKENS, FIELD_LIMITS, parseFieldType, RESERVED_FIELDS, structure
18
22
  * screen nobody declared — are `dataProblems` below, with a code each, so an
19
23
  * import report can say which collection and which field, not just "invalid".
20
24
  */
25
+ const placementSettingSchema = z.object({
26
+ type: z.enum(['string', 'url']),
27
+ label: z.string().min(1).max(60),
28
+ required: z.boolean().optional(),
29
+ placeholder: z.string().max(200).optional(),
30
+ });
31
+ /**
32
+ * How deep a folder's rows may nest, counting its own rows as the first
33
+ * level (ADR-A21): Issues → sprints → issues is two. Each level is listed
34
+ * only when a person opens its parent, so depth costs nothing while closed;
35
+ * the limit is for the sidebar's width and a person's patience.
36
+ */
37
+ export const MAX_FOLDER_DEPTH = 4;
38
+ /**
39
+ * "New sprint" on a folder's level (ADR-A21): one of the app's tools, run
40
+ * through the screen's door with the name the person typed in `titleField`
41
+ * and, below the first level, the row it is created under in `parentField`.
42
+ */
43
+ const folderCreateSchema = z.object({
44
+ tool: z.string().min(1).max(64),
45
+ /** "sprint", in "New sprint". */
46
+ noun: z.string().min(1).max(40).optional(),
47
+ titleField: z.string().regex(FIELD_NAME, 'A field name is letters, digits and _, starting with a lower-case letter.').optional(),
48
+ parentField: z.string().regex(FIELD_NAME, 'A field name is letters, digits and _, starting with a lower-case letter.').optional(),
49
+ });
50
+ /** A level below a folder's own rows: what they are called, and how one is made. */
51
+ const folderLevelSchema = z.object({
52
+ noun: z.string().min(1).max(60).optional(),
53
+ create: folderCreateSchema.optional(),
54
+ });
55
+ /** The sizes a Home card or a project widget can be drawn at (CC11-F10). */
56
+ export const HOME_CARD_SIZES = ['small', 'medium', 'large'];
57
+ /** The kinds drawn as a card, which say the sizes they draw well at. */
58
+ export const SIZED_PLACEMENT_KINDS = ['home', 'project-widget'];
59
+ /** A stable identity for one independently addable placement offering. */
60
+ export const PLACEMENT_KEY = /^[a-z][a-z0-9-]{0,39}$/;
61
+ export function effectivePlacementKey(one) {
62
+ return one.key ?? `${one.kind}:${one.screen}`;
63
+ }
21
64
  const placementSchema = z.object({
22
- kind: z.enum(['project-tab', 'project-sidebar', 'workspace-sidebar']),
65
+ key: z.string().regex(PLACEMENT_KEY).optional(),
66
+ /**
67
+ * `home`: a card on a person's Home, drawn by `screen` at one of `sizes`.
68
+ * `project-widget`: a card on a project's page, drawn at one of `sizes`.
69
+ * `project-tab` is retired (30 Sep 2026): still read from a published
70
+ * manifest, refused in a new one (`placement_tab_retired`).
71
+ * `public-page`: a screen people without a Brydio account open at the
72
+ * page's own address, once an admin turns it on (P3). It has no `sizes`,
73
+ * `children` or `settings` (`placement_public_shape`), and it reaches only
74
+ * collections marked `publicRead` or `publicSubmit` and tools marked
75
+ * `public`.
76
+ * `chat-card`: a screen drawn inside a chat message the app posted with
77
+ * a handler's `chat.post` (FO03). Nobody places it by hand, so it has no
78
+ * `sizes`, `children` or `settings` (`placement_chat_card_shape`); its
79
+ * screen sees `placement.kind === 'chat-card'` and the card's `route`.
80
+ */
81
+ kind: z.enum(['project-tab', 'project-widget', 'project-sidebar', 'workspace-sidebar', 'home', 'public-page', 'chat-card']),
23
82
  screen: z.string().min(1).max(FIELD_LIMITS.nameChars),
83
+ /** For `home` and `project-widget` only, and required there: the sizes the screen draws well at. */
84
+ sizes: z.array(z.enum(HOME_CARD_SIZES)).max(3).optional(),
24
85
  label: z.string().min(1).max(60).optional(),
25
86
  icon: z.string().max(60).optional(),
87
+ settings: z.record(z.string().regex(FIELD_NAME), placementSettingSchema).optional(),
88
+ /**
89
+ * A folder (A2-F02-S02): a sidebar item whose rows one of the app's read
90
+ * tools lists live, only while it is open. It can nest, and offer "New …"
91
+ * at each level, when the app opts in (ADR-A21).
92
+ */
93
+ children: z
94
+ .object({
95
+ tool: z.string().min(1).max(64),
96
+ refreshSeconds: z.number().int().min(15).max(3600).optional(),
97
+ cap: z.number().int().min(1).max(50).optional(),
98
+ noun: z.string().min(1).max(60).optional(),
99
+ /** A "New …" row at the folder's first level (ADR-A21). */
100
+ create: folderCreateSchema.optional(),
101
+ /**
102
+ * The levels under the first, in order (ADR-A21). A row the tool marks
103
+ * `hasChildren` opens only when a level is declared for it; without
104
+ * `nested` the folder stays one flat list.
105
+ */
106
+ nested: z.array(folderLevelSchema).max(8).optional(),
107
+ })
108
+ .optional(),
26
109
  });
27
110
  const collectionSchema = z.object({
28
111
  /** Field name to type, in the manifest's spelling (`"member?"`, `["todo","done"]`). */
@@ -31,6 +114,49 @@ const collectionSchema = z.object({
31
114
  search: z.array(z.string()).optional(),
32
115
  /** The singular noun the tools are named with: `issue` gives `create_issue`. */
33
116
  label: z.string().optional(),
117
+ /**
118
+ * Fields defined at runtime as records of another collection (P5): `fields`
119
+ * names it, and `table`, when given, the `string` field on both naming
120
+ * which table a row or a definition belongs to.
121
+ */
122
+ openSchema: z
123
+ .object({
124
+ fields: z.string().min(1).max(FIELD_LIMITS.nameChars),
125
+ table: z.string().min(1).max(FIELD_LIMITS.nameChars).optional(),
126
+ })
127
+ .strict()
128
+ .optional(),
129
+ /**
130
+ * A visitor on one of the app's public pages may `get` and `list` every
131
+ * record of this collection in that instance (P3).
132
+ */
133
+ publicRead: z.boolean().optional(),
134
+ /**
135
+ * A visitor on one of the app's public pages may create records here, and
136
+ * nothing else: no update, remove or batch, and no get or list unless
137
+ * `publicRead` is set too (P3).
138
+ */
139
+ publicSubmit: z.boolean().optional(),
140
+ /**
141
+ * Brydio emails the visitor once after a public submission here (FO04):
142
+ * `field` names a `string` field holding their address; `subject` and
143
+ * `message` are short plain text; `link` adds a button back to the page.
144
+ * Needs `publicSubmit`. Brydio decides when, how often and from whom.
145
+ */
146
+ confirmEmail: confirmEmailSchema.optional(),
147
+ /**
148
+ * Answers nobody can tie to who gave them (P13): `group` names a
149
+ * structured field (a number, choice, date, boolean, token or project)
150
+ * the answers are read by, a whole group at a time and only once it holds
151
+ * `minimum` answers (at least 5, the default). Brydio keeps no writer,
152
+ * cuts times to the day, tells no live change, and refuses an update, a
153
+ * batch, a watch and a create naming its own writer.
154
+ */
155
+ anonymous: anonymousSchema.optional(),
156
+ /** One of its string[] fields: the user ids who alone may read a record, when it names any (P16). */
157
+ readers: readersSchema.optional(),
158
+ /** One of its string[] fields: the user ids who alone may change a record, when it names any (DW06). */
159
+ editors: editorsSchema.optional(),
34
160
  });
35
161
  const screenSchema = z.object({
36
162
  /**
@@ -67,12 +193,45 @@ const customToolSchema = z.object({
67
193
  write: z.boolean().optional(),
68
194
  /** The collection it works on, when it works on one: its grant then needs that collection too. */
69
195
  collection: z.string().optional(),
196
+ /**
197
+ * Callable from a public page (P3). Its handler then runs for a visitor,
198
+ * whose `data` is held to `publicRead` and `publicSubmit` and who can use
199
+ * nothing else of the workspace's.
200
+ */
201
+ public: z.boolean().optional(),
70
202
  });
71
203
  const toolsSchema = z.object({
72
204
  /** Off only when the app supplies every tool itself (A3-F08). */
73
- generated: z.boolean().optional(),
205
+ /**
206
+ * Off only when the app supplies every tool itself. `"read"` keeps the
207
+ * generated reads (get, list, search) and leaves every write to the app's
208
+ * own tools, where its rules about who may change what live (DW06).
209
+ */
210
+ generated: z.union([z.boolean(), z.literal('read')]).optional(),
74
211
  custom: z.array(customToolSchema).max(MAX_CUSTOM_TOOLS).optional(),
75
212
  });
213
+ /** Most secrets one app may declare (ADR-A24). */
214
+ export const MAX_APP_SECRETS = 20;
215
+ /** The longest value one secret may hold, in characters. */
216
+ export const MAX_SECRET_CHARS = 8 * 1024;
217
+ /** A secret's name, as a handler asks for it: `api_key`. */
218
+ export const SECRET_NAME = /^[a-z][a-z0-9_]{0,59}$/;
219
+ /**
220
+ * A secret the app needs (ADR-A24): named here, never valued here. The value
221
+ * is entered in the app's settings by an administrator, or stored by the
222
+ * app's own handler with `secrets.set`, and only that app's handlers can read
223
+ * it, with `secrets.get`. A screen never can.
224
+ *
225
+ * `install` (the default) is one value for the whole install; `instance` is
226
+ * one per instance.
227
+ */
228
+ const secretSchema = z.object({
229
+ name: z.string().regex(SECRET_NAME, 'A secret name is lower case letters, digits and underscores, starting with a letter.'),
230
+ label: z.string().min(1).max(60),
231
+ description: z.string().max(300).optional(),
232
+ required: z.boolean().optional(),
233
+ scope: z.enum(['install', 'instance']).optional(),
234
+ });
76
235
  const grantsSchema = z.object({
77
236
  tools: z.array(z.string().max(100)).max(200).optional(),
78
237
  collections: z.array(z.string().max(100)).max(FIELD_LIMITS.collections + 1).optional(),
@@ -84,6 +243,8 @@ const extensionShape = {
84
243
  tools: toolsSchema.optional(),
85
244
  screens: z.record(z.string(), screenSchema).optional(),
86
245
  grants: grantsSchema.optional(),
246
+ /** The secrets its handlers read (ADR-A24): names only, never values. */
247
+ secrets: z.array(secretSchema).max(MAX_APP_SECRETS).optional(),
87
248
  /**
88
249
  * How records move when the schema changes between versions (A3-F07).
89
250
  * Checked against the previous version when a version is published, and
@@ -97,13 +258,6 @@ const extensionShape = {
97
258
  */
98
259
  sdk: z.string().max(MANIFEST_LIMITS.versionChars).regex(SEMVER_FORMAT).optional(),
99
260
  };
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
261
  export function dataProblems(additions, options = {}) {
108
262
  const problems = [];
109
263
  const collections = Object.entries(additions.data ?? {});
@@ -218,6 +372,38 @@ export function dataProblems(additions, options = {}) {
218
372
  }
219
373
  }
220
374
  }
375
+ // Open-schema collections (P5): the companion is declared, shaped as the
376
+ // host reads it, and defines one collection's fields at most.
377
+ const companions = new Map();
378
+ for (const [collection, declared] of collections) {
379
+ if (!declared.openSchema)
380
+ continue;
381
+ for (const problem of openSchemaProblems(collection, declared.openSchema, additions.data ?? {})) {
382
+ problems.push({ ...problem, collection });
383
+ }
384
+ const other = companions.get(declared.openSchema.fields);
385
+ if (other) {
386
+ problems.push({
387
+ code: 'data_open_fields_unknown',
388
+ collection,
389
+ message: `${declared.openSchema.fields} already defines ${other}'s fields; give ${collection} its own.`,
390
+ });
391
+ }
392
+ companions.set(declared.openSchema.fields, collection);
393
+ }
394
+ // A confirmation email names a string field of a collection visitors submit to (FO04).
395
+ for (const [collection, declared] of collections) {
396
+ problems.push(...confirmEmailProblems(collection, declared));
397
+ }
398
+ // An anonymous collection groups its answers by one of its structured fields, names nobody (P13).
399
+ for (const [collection, declared] of collections) {
400
+ problems.push(...anonymousProblems(collection, declared));
401
+ }
402
+ // A collection whose records name their readers names a list of user ids to keep them in (P16).
403
+ for (const [collection, declared] of collections) {
404
+ problems.push(...readersProblems(collection, declared));
405
+ problems.push(...editorsProblems(collection, declared));
406
+ }
221
407
  // What the app keeps must be what it asks to keep (A3-F06-S01): a
222
408
  // collection the grants leave out would be data a workspace never agreed to
223
409
  // hold, found only when the first write is refused.
@@ -234,14 +420,133 @@ export function dataProblems(additions, options = {}) {
234
420
  }
235
421
  }
236
422
  problems.push(...customToolProblems(additions, options));
423
+ problems.push(...secretProblems(additions, options));
237
424
  const screens = new Set(Object.keys(additions.screens ?? {}));
425
+ const placementKeys = new Set();
238
426
  for (const placement of additions.placements ?? []) {
427
+ const key = effectivePlacementKey(placement);
428
+ if (placementKeys.has(key)) {
429
+ problems.push({
430
+ code: 'placement_key_taken',
431
+ message: `Two placements use the key "${key}"; each independently addable placement needs its own key.`,
432
+ });
433
+ }
434
+ else {
435
+ placementKeys.add(key);
436
+ }
239
437
  if (!screens.has(placement.screen)) {
240
438
  problems.push({
241
439
  code: 'placement_screen_unknown',
242
440
  message: `A ${placement.kind} placement opens "${placement.screen}", which is not one of the app's screens.`,
243
441
  });
244
442
  }
443
+ // A card must say how big it can be drawn (CC11-F10-S01).
444
+ if (placement.kind === 'home' && !placement.sizes?.length) {
445
+ problems.push({
446
+ code: 'placement_home_sizes',
447
+ message: `The Home card "${placement.screen}" must say which sizes it draws at: small, medium or large.`,
448
+ });
449
+ }
450
+ if (placement.kind === 'project-widget' && !placement.sizes?.length) {
451
+ problems.push({
452
+ code: 'placement_widget_sizes',
453
+ message: `The project widget "${placement.screen}" must say which sizes it draws at: small, medium or large.`,
454
+ });
455
+ }
456
+ if (!SIZED_PLACEMENT_KINDS.includes(placement.kind) && placement.sizes?.length) {
457
+ problems.push({
458
+ code: 'placement_sizes_not_home',
459
+ message: `Only a Home card or a project widget has sizes; the ${placement.kind} placement "${placement.screen}" cannot.`,
460
+ });
461
+ }
462
+ if (options.retired && placement.kind === 'project-tab') {
463
+ problems.push({
464
+ code: 'placement_tab_retired',
465
+ message: 'Project tabs are retired; declare a project-widget with sizes instead.',
466
+ });
467
+ }
468
+ // A public page is one screen at its own address: nothing to list in a
469
+ // sidebar, and nothing for an admin to fill in when it is placed.
470
+ if (placement.kind === 'public-page' && (placement.children || placement.settings)) {
471
+ problems.push({
472
+ code: 'placement_public_shape',
473
+ message: `The public page "${placement.screen}" can have no children or settings: it is one screen at its own address.`,
474
+ });
475
+ }
476
+ // A chat card is drawn at the message's own size, in a message nobody
477
+ // places by hand (FO03): nothing to size, configure or list.
478
+ if (placement.kind === 'chat-card' && (placement.sizes?.length || placement.children || placement.settings)) {
479
+ problems.push({
480
+ code: 'placement_chat_card_shape',
481
+ message: `The chat card "${placement.screen}" cannot have sizes, children or settings.`,
482
+ });
483
+ }
484
+ }
485
+ // A public page with nothing public could show a visitor nothing at all.
486
+ if ((additions.placements ?? []).some(placement => placement.kind === 'public-page')) {
487
+ const publicData = Object.values(additions.data ?? {}).some(declared => declared.publicRead || declared.publicSubmit);
488
+ const publicTool = (additions.tools?.custom ?? []).some(tool => tool.public);
489
+ if (!publicData && !publicTool) {
490
+ problems.push({
491
+ code: 'placement_public_nothing',
492
+ message: 'A public page needs something a visitor may use: mark a collection publicRead or publicSubmit, or a custom tool public.',
493
+ });
494
+ }
495
+ }
496
+ problems.push(...folderProblems(additions));
497
+ return problems;
498
+ }
499
+ /**
500
+ * A folder that nests or creates (ADR-A21) must stay within
501
+ * `MAX_FOLDER_DEPTH`, and each "New …" must name a tool the app has that
502
+ * changes records. Only the new keys are checked: a published manifest's
503
+ * existing folder still reads as it did.
504
+ */
505
+ function folderProblems(additions) {
506
+ const problems = [];
507
+ const custom = new Map((additions.tools?.custom ?? []).map(tool => [tool.name, tool]));
508
+ const generatedWrites = additions.tools?.generated === false || additions.tools?.generated === 'read'
509
+ ? new Set()
510
+ : new Set(Object.entries(additions.data ?? {}).flatMap(([name, declared]) => {
511
+ const label = labelOf(name, declared.label);
512
+ return [`create_${label}`, `update_${label}`, `delete_${label}`, `batch_${label}s`];
513
+ }));
514
+ const generatedReads = additions.tools?.generated === false
515
+ ? new Set()
516
+ : new Set(Object.entries(additions.data ?? {}).flatMap(([name, declared]) => {
517
+ const label = labelOf(name, declared.label);
518
+ return [`get_${label}`, `list_${label}s`, `search_${label}s`];
519
+ }));
520
+ for (const placement of additions.placements ?? []) {
521
+ const children = placement.children;
522
+ if (!children)
523
+ continue;
524
+ const depth = 1 + (children.nested?.length ?? 0);
525
+ if (depth > MAX_FOLDER_DEPTH) {
526
+ problems.push({
527
+ code: 'placement_children_too_deep',
528
+ message: `The "${placement.screen}" folder nests ${depth} levels deep; a sidebar folder may nest at most ${MAX_FOLDER_DEPTH}.`,
529
+ });
530
+ }
531
+ const creates = [children.create, ...(children.nested ?? []).map(level => level.create)];
532
+ for (const create of creates) {
533
+ if (!create)
534
+ continue;
535
+ const own = custom.get(create.tool);
536
+ const reads = own ? own.write !== true : generatedReads.has(create.tool);
537
+ if (!own && !reads && !generatedWrites.has(create.tool)) {
538
+ problems.push({
539
+ code: 'placement_create_tool_unknown',
540
+ message: `The "${placement.screen}" folder creates with ${create.tool}, which is not one of the app's tools.`,
541
+ });
542
+ }
543
+ else if (reads) {
544
+ problems.push({
545
+ code: 'placement_create_not_write',
546
+ 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.`,
547
+ });
548
+ }
549
+ }
245
550
  }
246
551
  return problems;
247
552
  }
@@ -261,18 +566,28 @@ function customToolProblems(additions, options) {
261
566
  : new Set(Object.entries(additions.data ?? {}).flatMap(([name, declared]) => {
262
567
  const label = labelOf(name, declared.label);
263
568
  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
- ];
569
+ const reads = [`get_${label}`, `list_${plural}`, `search_${plural}`];
570
+ // With only reads generated, the write names are the app's to use.
571
+ return additions.tools?.generated === 'read'
572
+ ? reads
573
+ : [...reads, `create_${label}`, `update_${label}`, `delete_${label}`, `batch_${plural}`];
273
574
  }));
274
575
  const seen = new Set();
275
576
  const granted = additions.grants?.tools ?? [];
577
+ // Another app's tool, asked for by its assistant name `<slug>__<tool>`
578
+ // (FO07): never an unknown custom tool of this app, but naming the app
579
+ // itself would be a way round its own grants.
580
+ const name = additions.name;
581
+ const own = typeof name === 'string' ? appToolPrefix(name) : null;
582
+ for (const grant of granted) {
583
+ const qualified = crossAppTool(grant);
584
+ if (qualified && own !== null && qualified.app === own) {
585
+ problems.push({
586
+ code: 'grant_tool_cross_app_self',
587
+ message: `${grant} names this app's own tool: ask for ${qualified.tool} instead.`,
588
+ });
589
+ }
590
+ }
276
591
  for (const tool of custom) {
277
592
  if (generated.has(tool.name) || seen.has(tool.name)) {
278
593
  problems.push({
@@ -309,20 +624,67 @@ function customToolProblems(additions, options) {
309
624
  }
310
625
  return problems;
311
626
  }
627
+ /**
628
+ * Another installed app's tool, as `grants.tools` names it (FO07): the name
629
+ * the assistant knows it by, `<slug>__<tool>`. Null for anything else.
630
+ * A handler calls it with `tools.call('<slug>__<tool>', input)`.
631
+ */
632
+ export function crossAppTool(name) {
633
+ const at = name.indexOf('__');
634
+ if (at <= 0 || at + 2 >= name.length)
635
+ return null;
636
+ return { app: name.slice(0, at), tool: name.slice(at + 2) };
637
+ }
638
+ /** An app's name as the front of its tools' assistant names: `issue-tracker` → `issue_tracker`. */
639
+ export const appToolPrefix = (name) => name
640
+ .replace(/[^A-Za-z0-9_]+/g, '_')
641
+ .replace(/_+/g, '_')
642
+ .replace(/^_+|_+$/g, '');
643
+ /**
644
+ * Each secret declared once, and asked for (ADR-A24): an app that declares
645
+ * secrets without the `secrets` host grant could never read one.
646
+ */
647
+ function secretProblems(additions, options) {
648
+ const problems = [];
649
+ const secrets = additions.secrets ?? [];
650
+ const seen = new Set();
651
+ for (const secret of secrets) {
652
+ if (seen.has(secret.name)) {
653
+ problems.push({
654
+ code: 'secret_name_taken',
655
+ field: secret.name,
656
+ message: `The secret ${secret.name} is declared twice; keep one.`,
657
+ });
658
+ }
659
+ seen.add(secret.name);
660
+ }
661
+ const host = additions.grants?.host ?? [];
662
+ if (options.grants !== false && secrets.length && !host.includes('secrets') && !host.includes('*')) {
663
+ problems.push({
664
+ code: 'grant_secrets_missing',
665
+ message: 'The app declares secrets but does not ask to read them: add "secrets" to grants.host.',
666
+ });
667
+ }
668
+ return problems;
669
+ }
312
670
  const refuse = (additions, ctx, options = {}) => {
313
671
  for (const problem of dataProblems(additions, options)) {
314
672
  ctx.addIssue({
315
673
  code: 'custom',
316
674
  message: problem.message,
317
- path: problem.code === 'grant_tool_missing'
675
+ path: problem.code === 'grant_tool_missing' || problem.code === 'grant_tool_cross_app_self'
318
676
  ? ['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
- : [],
677
+ : problem.code === 'grant_secrets_missing'
678
+ ? ['grants', 'host']
679
+ : problem.code === 'secret_name_taken'
680
+ ? ['secrets']
681
+ : problem.code.startsWith('grant_')
682
+ ? ['grants', 'collections']
683
+ : problem.code.startsWith('custom_')
684
+ ? ['tools', 'custom']
685
+ : problem.collection
686
+ ? ['data', problem.collection, ...(problem.field ? ['schema', problem.field] : [])]
687
+ : [],
326
688
  params: { code: problem.code },
327
689
  });
328
690
  }
@@ -337,6 +699,14 @@ export const manifestExtensionsSchema = z.object(extensionShape).superRefine((ad
337
699
  export const storedExtensionsSchema = z.object(extensionShape).superRefine((additions, ctx) => refuse(additions, ctx, { grants: false }));
338
700
  /** E5's manifest with the additions: the whole `.brydio/app.json` of an app. */
339
701
  export const appManifestSchema = manifestSchema.extend(extensionShape).superRefine((additions, ctx) => refuse(additions, ctx));
702
+ /**
703
+ * A manifest being written now, as `brydio validate`, `build` and `dev` read
704
+ * it: the same, and nothing retired (`placement_tab_retired`) — which a
705
+ * published version may still hold.
706
+ */
707
+ export const newAppManifestSchema = manifestSchema
708
+ .extend(extensionShape)
709
+ .superRefine((additions, ctx) => refuse(additions, ctx, { retired: true }));
340
710
  /**
341
711
  * The singular a collection's tools are named with: the manifest's, else the
342
712
  * collection's name with a trailing "s" taken off (`issues` → `issue`).
@@ -353,8 +723,22 @@ export function labelOf(collection, label) {
353
723
  */
354
724
  export function collectionsOf(manifest) {
355
725
  const parsed = storedExtensionsSchema.parse({ data: manifest.data ?? {} });
726
+ const owners = new Map(Object.entries(parsed.data ?? {}).flatMap(([name, declared]) => declared.openSchema ? [[declared.openSchema.fields, { name, flag: declared.openSchema }]] : []));
356
727
  return Object.entries(parsed.data ?? {}).map(([name, declared]) => {
357
- const fields = Object.fromEntries(Object.entries(declared.schema).map(([field, raw]) => [field, parseFieldType(raw)]));
728
+ const owner = owners.get(name);
729
+ // The field naming a row's table is kept in plain on both sides, so a
730
+ // table is filtered and counted without decrypting a row (P5).
731
+ const tableField = declared.openSchema?.table ?? owner?.flag.table;
732
+ const fields = Object.fromEntries(Object.entries(declared.schema).map(([field, raw]) => {
733
+ const type = parseFieldType(raw);
734
+ if (field === tableField)
735
+ return [field, { ...type, plain: true }];
736
+ // A record's readers or editors may name a whole team (P16, DW06).
737
+ return [
738
+ field,
739
+ (field === declared.readers || field === declared.editors) && type.kind === 'string[]' ? { ...type, maxEntries: READERS_LIMIT } : type,
740
+ ];
741
+ }));
358
742
  const label = labelOf(name, declared.label);
359
743
  const names = Object.keys(fields);
360
744
  return {
@@ -366,6 +750,13 @@ export function collectionsOf(manifest) {
366
750
  sortable: names.filter(field => isSortable(fields[field])),
367
751
  search: declared.search ?? [],
368
752
  projectField: names.find(field => fields[field].kind === 'project') ?? null,
753
+ ...(declared.openSchema
754
+ ? { openSchema: { fields: declared.openSchema.fields, table: declared.openSchema.table ?? null } }
755
+ : {}),
756
+ ...(owner ? { definesFieldsOf: owner.name } : {}),
757
+ ...(declared.anonymous ? { anonymous: anonymousSpec(declared.anonymous) } : {}),
758
+ ...(declared.readers ? { readers: declared.readers } : {}),
759
+ ...(declared.editors ? { editors: declared.editors } : {}),
369
760
  };
370
761
  });
371
762
  }
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;