@brydio/manifest 0.1.0-alpha.5 → 0.1.0-alpha.7

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.d.ts CHANGED
@@ -26,6 +26,13 @@ declare const placementSettingSchema: z.ZodObject<{
26
26
  placeholder: z.ZodOptional<z.ZodString>;
27
27
  }, z.core.$strip>;
28
28
  export type PlacementSettingSpec = z.infer<typeof placementSettingSchema>;
29
+ /**
30
+ * How deep a folder's rows may nest, counting its own rows as the first
31
+ * level (ADR-A21): Issues → sprints → issues is two. Each level is listed
32
+ * only when a person opens its parent, so depth costs nothing while closed;
33
+ * the limit is for the sidebar's width and a person's patience.
34
+ */
35
+ export declare const MAX_FOLDER_DEPTH = 4;
29
36
  /** Most custom tools one app may declare (A3-F08). */
30
37
  export declare const MAX_CUSTOM_TOOLS = 20;
31
38
  /** A tool name the model and a policy row can both use as it is. */
@@ -43,6 +50,32 @@ declare const customToolSchema: z.ZodObject<{
43
50
  collection: z.ZodOptional<z.ZodString>;
44
51
  }, z.core.$strip>;
45
52
  export type CustomToolSpec = z.infer<typeof customToolSchema>;
53
+ /** Most secrets one app may declare (ADR-A24). */
54
+ export declare const MAX_APP_SECRETS = 20;
55
+ /** The longest value one secret may hold, in characters. */
56
+ export declare const MAX_SECRET_CHARS: number;
57
+ /** A secret's name, as a handler asks for it: `api_key`. */
58
+ export declare const SECRET_NAME: RegExp;
59
+ /**
60
+ * A secret the app needs (ADR-A24): named here, never valued here. The value
61
+ * is entered in the app's settings by an administrator, or stored by the
62
+ * app's own handler with `secrets.set`, and only that app's handlers can read
63
+ * it, with `secrets.get`. A screen never can.
64
+ *
65
+ * `install` (the default) is one value for the whole install; `instance` is
66
+ * one per instance.
67
+ */
68
+ declare const secretSchema: z.ZodObject<{
69
+ name: z.ZodString;
70
+ label: z.ZodString;
71
+ description: z.ZodOptional<z.ZodString>;
72
+ required: z.ZodOptional<z.ZodBoolean>;
73
+ scope: z.ZodOptional<z.ZodEnum<{
74
+ instance: "instance";
75
+ install: "install";
76
+ }>>;
77
+ }, z.core.$strip>;
78
+ export type SecretSpec = z.infer<typeof secretSchema>;
46
79
  declare const extensionShape: {
47
80
  placements: z.ZodOptional<z.ZodArray<z.ZodObject<{
48
81
  kind: z.ZodEnum<{
@@ -62,6 +95,27 @@ declare const extensionShape: {
62
95
  required: z.ZodOptional<z.ZodBoolean>;
63
96
  placeholder: z.ZodOptional<z.ZodString>;
64
97
  }, z.core.$strip>>>;
98
+ children: z.ZodOptional<z.ZodObject<{
99
+ tool: z.ZodString;
100
+ refreshSeconds: z.ZodOptional<z.ZodNumber>;
101
+ cap: z.ZodOptional<z.ZodNumber>;
102
+ noun: z.ZodOptional<z.ZodString>;
103
+ create: z.ZodOptional<z.ZodObject<{
104
+ tool: z.ZodString;
105
+ noun: z.ZodOptional<z.ZodString>;
106
+ titleField: z.ZodOptional<z.ZodString>;
107
+ parentField: z.ZodOptional<z.ZodString>;
108
+ }, z.core.$strip>>;
109
+ nested: z.ZodOptional<z.ZodArray<z.ZodObject<{
110
+ noun: z.ZodOptional<z.ZodString>;
111
+ create: z.ZodOptional<z.ZodObject<{
112
+ tool: z.ZodString;
113
+ noun: z.ZodOptional<z.ZodString>;
114
+ titleField: z.ZodOptional<z.ZodString>;
115
+ parentField: z.ZodOptional<z.ZodString>;
116
+ }, z.core.$strip>>;
117
+ }, z.core.$strip>>>;
118
+ }, z.core.$strip>>;
65
119
  }, z.core.$strip>>>;
66
120
  data: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
67
121
  schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
@@ -87,6 +141,17 @@ declare const extensionShape: {
87
141
  collections: z.ZodOptional<z.ZodArray<z.ZodString>>;
88
142
  host: z.ZodOptional<z.ZodArray<z.ZodString>>;
89
143
  }, z.core.$strip>>;
144
+ /** The secrets its handlers read (ADR-A24): names only, never values. */
145
+ secrets: z.ZodOptional<z.ZodArray<z.ZodObject<{
146
+ name: z.ZodString;
147
+ label: z.ZodString;
148
+ description: z.ZodOptional<z.ZodString>;
149
+ required: z.ZodOptional<z.ZodBoolean>;
150
+ scope: z.ZodOptional<z.ZodEnum<{
151
+ instance: "instance";
152
+ install: "install";
153
+ }>>;
154
+ }, z.core.$strip>>>;
90
155
  /**
91
156
  * How records move when the schema changes between versions (A3-F07).
92
157
  * Checked against the previous version when a version is published, and
@@ -133,7 +198,7 @@ export interface DataProblem {
133
198
  field?: string;
134
199
  message: string;
135
200
  }
136
- export type DataProblemCode = FieldTypeInvalid['code'] | 'data_too_many_collections' | 'data_too_many_fields' | 'data_collection_name_format' | 'data_field_name_format' | 'data_field_reserved' | 'data_label_format' | 'data_label_taken' | 'data_project_field_twice' | 'data_search_unknown_field' | 'data_search_not_text' | 'grant_collection_missing' | 'grant_tool_missing' | 'custom_name_taken' | 'custom_collection_unknown' | 'custom_input_invalid' | 'placement_screen_unknown';
201
+ export type DataProblemCode = FieldTypeInvalid['code'] | 'data_too_many_collections' | 'data_too_many_fields' | 'data_collection_name_format' | 'data_field_name_format' | 'data_field_reserved' | 'data_label_format' | 'data_label_taken' | 'data_project_field_twice' | 'data_search_unknown_field' | 'data_search_not_text' | 'grant_collection_missing' | 'grant_tool_missing' | 'custom_name_taken' | 'custom_collection_unknown' | 'custom_input_invalid' | 'placement_screen_unknown' | 'placement_children_too_deep' | 'placement_create_tool_unknown' | 'placement_create_not_write' | 'secret_name_taken' | 'grant_secrets_missing';
137
202
  type Additions = z.infer<z.ZodObject<typeof extensionShape>>;
138
203
  /**
139
204
  * Everything wrong with an app's additions that a type cannot say.
@@ -165,6 +230,27 @@ export declare const manifestExtensionsSchema: z.ZodObject<{
165
230
  required: z.ZodOptional<z.ZodBoolean>;
166
231
  placeholder: z.ZodOptional<z.ZodString>;
167
232
  }, z.core.$strip>>>;
233
+ children: z.ZodOptional<z.ZodObject<{
234
+ tool: z.ZodString;
235
+ refreshSeconds: z.ZodOptional<z.ZodNumber>;
236
+ cap: z.ZodOptional<z.ZodNumber>;
237
+ noun: z.ZodOptional<z.ZodString>;
238
+ create: z.ZodOptional<z.ZodObject<{
239
+ tool: z.ZodString;
240
+ noun: z.ZodOptional<z.ZodString>;
241
+ titleField: z.ZodOptional<z.ZodString>;
242
+ parentField: z.ZodOptional<z.ZodString>;
243
+ }, z.core.$strip>>;
244
+ nested: z.ZodOptional<z.ZodArray<z.ZodObject<{
245
+ noun: z.ZodOptional<z.ZodString>;
246
+ create: z.ZodOptional<z.ZodObject<{
247
+ tool: z.ZodString;
248
+ noun: z.ZodOptional<z.ZodString>;
249
+ titleField: z.ZodOptional<z.ZodString>;
250
+ parentField: z.ZodOptional<z.ZodString>;
251
+ }, z.core.$strip>>;
252
+ }, z.core.$strip>>>;
253
+ }, z.core.$strip>>;
168
254
  }, z.core.$strip>>>;
169
255
  data: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
170
256
  schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
@@ -190,6 +276,16 @@ export declare const manifestExtensionsSchema: z.ZodObject<{
190
276
  collections: z.ZodOptional<z.ZodArray<z.ZodString>>;
191
277
  host: z.ZodOptional<z.ZodArray<z.ZodString>>;
192
278
  }, z.core.$strip>>;
279
+ secrets: z.ZodOptional<z.ZodArray<z.ZodObject<{
280
+ name: z.ZodString;
281
+ label: z.ZodString;
282
+ description: z.ZodOptional<z.ZodString>;
283
+ required: z.ZodOptional<z.ZodBoolean>;
284
+ scope: z.ZodOptional<z.ZodEnum<{
285
+ instance: "instance";
286
+ install: "install";
287
+ }>>;
288
+ }, z.core.$strip>>>;
193
289
  migrations: z.ZodOptional<z.ZodArray<z.ZodObject<{
194
290
  version: z.ZodString;
195
291
  steps: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
@@ -243,6 +339,27 @@ export declare const storedExtensionsSchema: z.ZodObject<{
243
339
  required: z.ZodOptional<z.ZodBoolean>;
244
340
  placeholder: z.ZodOptional<z.ZodString>;
245
341
  }, z.core.$strip>>>;
342
+ children: z.ZodOptional<z.ZodObject<{
343
+ tool: z.ZodString;
344
+ refreshSeconds: z.ZodOptional<z.ZodNumber>;
345
+ cap: z.ZodOptional<z.ZodNumber>;
346
+ noun: z.ZodOptional<z.ZodString>;
347
+ create: z.ZodOptional<z.ZodObject<{
348
+ tool: z.ZodString;
349
+ noun: z.ZodOptional<z.ZodString>;
350
+ titleField: z.ZodOptional<z.ZodString>;
351
+ parentField: z.ZodOptional<z.ZodString>;
352
+ }, z.core.$strip>>;
353
+ nested: z.ZodOptional<z.ZodArray<z.ZodObject<{
354
+ noun: z.ZodOptional<z.ZodString>;
355
+ create: z.ZodOptional<z.ZodObject<{
356
+ tool: z.ZodString;
357
+ noun: z.ZodOptional<z.ZodString>;
358
+ titleField: z.ZodOptional<z.ZodString>;
359
+ parentField: z.ZodOptional<z.ZodString>;
360
+ }, z.core.$strip>>;
361
+ }, z.core.$strip>>>;
362
+ }, z.core.$strip>>;
246
363
  }, z.core.$strip>>>;
247
364
  data: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
248
365
  schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
@@ -268,6 +385,16 @@ export declare const storedExtensionsSchema: z.ZodObject<{
268
385
  collections: z.ZodOptional<z.ZodArray<z.ZodString>>;
269
386
  host: z.ZodOptional<z.ZodArray<z.ZodString>>;
270
387
  }, z.core.$strip>>;
388
+ secrets: z.ZodOptional<z.ZodArray<z.ZodObject<{
389
+ name: z.ZodString;
390
+ label: z.ZodString;
391
+ description: z.ZodOptional<z.ZodString>;
392
+ required: z.ZodOptional<z.ZodBoolean>;
393
+ scope: z.ZodOptional<z.ZodEnum<{
394
+ instance: "instance";
395
+ install: "install";
396
+ }>>;
397
+ }, z.core.$strip>>>;
271
398
  migrations: z.ZodOptional<z.ZodArray<z.ZodObject<{
272
399
  version: z.ZodString;
273
400
  steps: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
@@ -318,7 +445,14 @@ export declare const appManifestSchema: z.ZodObject<{
318
445
  terms: z.ZodOptional<z.ZodString>;
319
446
  support: z.ZodOptional<z.ZodString>;
320
447
  }, z.core.$strip>>;
321
- icon: z.ZodOptional<z.ZodString>;
448
+ logo: z.ZodOptional<z.ZodObject<{
449
+ color: z.ZodString;
450
+ mono: z.ZodString;
451
+ }, z.core.$strip>>;
452
+ icon: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
453
+ color: z.ZodString;
454
+ mono: z.ZodString;
455
+ }, z.core.$strip>]>>;
322
456
  brandColor: z.ZodOptional<z.ZodString>;
323
457
  brandColorDark: z.ZodOptional<z.ZodString>;
324
458
  defaultPrompts: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -349,6 +483,27 @@ export declare const appManifestSchema: z.ZodObject<{
349
483
  required: z.ZodOptional<z.ZodBoolean>;
350
484
  placeholder: z.ZodOptional<z.ZodString>;
351
485
  }, z.core.$strip>>>;
486
+ children: z.ZodOptional<z.ZodObject<{
487
+ tool: z.ZodString;
488
+ refreshSeconds: z.ZodOptional<z.ZodNumber>;
489
+ cap: z.ZodOptional<z.ZodNumber>;
490
+ noun: z.ZodOptional<z.ZodString>;
491
+ create: z.ZodOptional<z.ZodObject<{
492
+ tool: z.ZodString;
493
+ noun: z.ZodOptional<z.ZodString>;
494
+ titleField: z.ZodOptional<z.ZodString>;
495
+ parentField: z.ZodOptional<z.ZodString>;
496
+ }, z.core.$strip>>;
497
+ nested: z.ZodOptional<z.ZodArray<z.ZodObject<{
498
+ noun: z.ZodOptional<z.ZodString>;
499
+ create: z.ZodOptional<z.ZodObject<{
500
+ tool: z.ZodString;
501
+ noun: z.ZodOptional<z.ZodString>;
502
+ titleField: z.ZodOptional<z.ZodString>;
503
+ parentField: z.ZodOptional<z.ZodString>;
504
+ }, z.core.$strip>>;
505
+ }, z.core.$strip>>>;
506
+ }, z.core.$strip>>;
352
507
  }, z.core.$strip>>>;
353
508
  data: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
354
509
  schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
@@ -374,6 +529,16 @@ export declare const appManifestSchema: z.ZodObject<{
374
529
  collections: z.ZodOptional<z.ZodArray<z.ZodString>>;
375
530
  host: z.ZodOptional<z.ZodArray<z.ZodString>>;
376
531
  }, z.core.$strip>>;
532
+ secrets: z.ZodOptional<z.ZodArray<z.ZodObject<{
533
+ name: z.ZodString;
534
+ label: z.ZodString;
535
+ description: z.ZodOptional<z.ZodString>;
536
+ required: z.ZodOptional<z.ZodBoolean>;
537
+ scope: z.ZodOptional<z.ZodEnum<{
538
+ instance: "instance";
539
+ install: "install";
540
+ }>>;
541
+ }, z.core.$strip>>>;
377
542
  migrations: z.ZodOptional<z.ZodArray<z.ZodObject<{
378
543
  version: z.ZodString;
379
544
  steps: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
package/src/schema.js CHANGED
@@ -24,12 +24,57 @@ const placementSettingSchema = z.object({
24
24
  required: z.boolean().optional(),
25
25
  placeholder: z.string().max(200).optional(),
26
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
+ });
27
51
  const placementSchema = z.object({
28
52
  kind: z.enum(['project-tab', 'project-sidebar', 'workspace-sidebar']),
29
53
  screen: z.string().min(1).max(FIELD_LIMITS.nameChars),
30
54
  label: z.string().min(1).max(60).optional(),
31
55
  icon: z.string().max(60).optional(),
32
56
  settings: z.record(z.string().regex(FIELD_NAME), placementSettingSchema).optional(),
57
+ /**
58
+ * A folder (A2-F02-S02): a sidebar item whose rows one of the app's read
59
+ * tools lists live, only while it is open. It can nest, and offer "New …"
60
+ * at each level, when the app opts in (ADR-A21).
61
+ */
62
+ children: z
63
+ .object({
64
+ tool: z.string().min(1).max(64),
65
+ refreshSeconds: z.number().int().min(15).max(3600).optional(),
66
+ cap: z.number().int().min(1).max(50).optional(),
67
+ noun: z.string().min(1).max(60).optional(),
68
+ /** A "New …" row at the folder's first level (ADR-A21). */
69
+ create: folderCreateSchema.optional(),
70
+ /**
71
+ * The levels under the first, in order (ADR-A21). A row the tool marks
72
+ * `hasChildren` opens only when a level is declared for it; without
73
+ * `nested` the folder stays one flat list.
74
+ */
75
+ nested: z.array(folderLevelSchema).max(8).optional(),
76
+ })
77
+ .optional(),
33
78
  });
34
79
  const collectionSchema = z.object({
35
80
  /** Field name to type, in the manifest's spelling (`"member?"`, `["todo","done"]`). */
@@ -80,6 +125,28 @@ const toolsSchema = z.object({
80
125
  generated: z.boolean().optional(),
81
126
  custom: z.array(customToolSchema).max(MAX_CUSTOM_TOOLS).optional(),
82
127
  });
128
+ /** Most secrets one app may declare (ADR-A24). */
129
+ export const MAX_APP_SECRETS = 20;
130
+ /** The longest value one secret may hold, in characters. */
131
+ export const MAX_SECRET_CHARS = 8 * 1024;
132
+ /** A secret's name, as a handler asks for it: `api_key`. */
133
+ export const SECRET_NAME = /^[a-z][a-z0-9_]{0,59}$/;
134
+ /**
135
+ * A secret the app needs (ADR-A24): named here, never valued here. The value
136
+ * is entered in the app's settings by an administrator, or stored by the
137
+ * app's own handler with `secrets.set`, and only that app's handlers can read
138
+ * it, with `secrets.get`. A screen never can.
139
+ *
140
+ * `install` (the default) is one value for the whole install; `instance` is
141
+ * one per instance.
142
+ */
143
+ const secretSchema = z.object({
144
+ name: z.string().regex(SECRET_NAME, 'A secret name is lower case letters, digits and underscores, starting with a letter.'),
145
+ label: z.string().min(1).max(60),
146
+ description: z.string().max(300).optional(),
147
+ required: z.boolean().optional(),
148
+ scope: z.enum(['install', 'instance']).optional(),
149
+ });
83
150
  const grantsSchema = z.object({
84
151
  tools: z.array(z.string().max(100)).max(200).optional(),
85
152
  collections: z.array(z.string().max(100)).max(FIELD_LIMITS.collections + 1).optional(),
@@ -91,6 +158,8 @@ const extensionShape = {
91
158
  tools: toolsSchema.optional(),
92
159
  screens: z.record(z.string(), screenSchema).optional(),
93
160
  grants: grantsSchema.optional(),
161
+ /** The secrets its handlers read (ADR-A24): names only, never values. */
162
+ secrets: z.array(secretSchema).max(MAX_APP_SECRETS).optional(),
94
163
  /**
95
164
  * How records move when the schema changes between versions (A3-F07).
96
165
  * Checked against the previous version when a version is published, and
@@ -241,6 +310,7 @@ export function dataProblems(additions, options = {}) {
241
310
  }
242
311
  }
243
312
  problems.push(...customToolProblems(additions, options));
313
+ problems.push(...secretProblems(additions, options));
244
314
  const screens = new Set(Object.keys(additions.screens ?? {}));
245
315
  for (const placement of additions.placements ?? []) {
246
316
  if (!screens.has(placement.screen)) {
@@ -250,6 +320,61 @@ export function dataProblems(additions, options = {}) {
250
320
  });
251
321
  }
252
322
  }
323
+ problems.push(...folderProblems(additions));
324
+ return problems;
325
+ }
326
+ /**
327
+ * A folder that nests or creates (ADR-A21) must stay within
328
+ * `MAX_FOLDER_DEPTH`, and each "New …" must name a tool the app has that
329
+ * changes records. Only the new keys are checked: a published manifest's
330
+ * existing folder still reads as it did.
331
+ */
332
+ function folderProblems(additions) {
333
+ const problems = [];
334
+ const custom = new Map((additions.tools?.custom ?? []).map(tool => [tool.name, tool]));
335
+ const generatedWrites = additions.tools?.generated === false
336
+ ? new Set()
337
+ : new Set(Object.entries(additions.data ?? {}).flatMap(([name, declared]) => {
338
+ const label = labelOf(name, declared.label);
339
+ return [`create_${label}`, `update_${label}`, `delete_${label}`, `batch_${label}s`];
340
+ }));
341
+ const generatedReads = additions.tools?.generated === false
342
+ ? new Set()
343
+ : new Set(Object.entries(additions.data ?? {}).flatMap(([name, declared]) => {
344
+ const label = labelOf(name, declared.label);
345
+ return [`get_${label}`, `list_${label}s`, `search_${label}s`];
346
+ }));
347
+ for (const placement of additions.placements ?? []) {
348
+ const children = placement.children;
349
+ if (!children)
350
+ continue;
351
+ const depth = 1 + (children.nested?.length ?? 0);
352
+ if (depth > MAX_FOLDER_DEPTH) {
353
+ problems.push({
354
+ code: 'placement_children_too_deep',
355
+ message: `The "${placement.screen}" folder nests ${depth} levels deep; a sidebar folder may nest at most ${MAX_FOLDER_DEPTH}.`,
356
+ });
357
+ }
358
+ const creates = [children.create, ...(children.nested ?? []).map(level => level.create)];
359
+ for (const create of creates) {
360
+ if (!create)
361
+ continue;
362
+ const own = custom.get(create.tool);
363
+ const reads = own ? own.write !== true : generatedReads.has(create.tool);
364
+ if (!own && !reads && !generatedWrites.has(create.tool)) {
365
+ problems.push({
366
+ code: 'placement_create_tool_unknown',
367
+ message: `The "${placement.screen}" folder creates with ${create.tool}, which is not one of the app's tools.`,
368
+ });
369
+ }
370
+ else if (reads) {
371
+ problems.push({
372
+ code: 'placement_create_not_write',
373
+ 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.`,
374
+ });
375
+ }
376
+ }
377
+ }
253
378
  return problems;
254
379
  }
255
380
  /**
@@ -316,6 +441,33 @@ function customToolProblems(additions, options) {
316
441
  }
317
442
  return problems;
318
443
  }
444
+ /**
445
+ * Each secret declared once, and asked for (ADR-A24): an app that declares
446
+ * secrets without the `secrets` host grant could never read one.
447
+ */
448
+ function secretProblems(additions, options) {
449
+ const problems = [];
450
+ const secrets = additions.secrets ?? [];
451
+ const seen = new Set();
452
+ for (const secret of secrets) {
453
+ if (seen.has(secret.name)) {
454
+ problems.push({
455
+ code: 'secret_name_taken',
456
+ field: secret.name,
457
+ message: `The secret ${secret.name} is declared twice; keep one.`,
458
+ });
459
+ }
460
+ seen.add(secret.name);
461
+ }
462
+ const host = additions.grants?.host ?? [];
463
+ if (options.grants !== false && secrets.length && !host.includes('secrets') && !host.includes('*')) {
464
+ problems.push({
465
+ code: 'grant_secrets_missing',
466
+ message: 'The app declares secrets but does not ask to read them: add "secrets" to grants.host.',
467
+ });
468
+ }
469
+ return problems;
470
+ }
319
471
  const refuse = (additions, ctx, options = {}) => {
320
472
  for (const problem of dataProblems(additions, options)) {
321
473
  ctx.addIssue({
@@ -323,13 +475,17 @@ const refuse = (additions, ctx, options = {}) => {
323
475
  message: problem.message,
324
476
  path: problem.code === 'grant_tool_missing'
325
477
  ? ['grants', 'tools']
326
- : problem.code.startsWith('grant_')
327
- ? ['grants', 'collections']
328
- : problem.code.startsWith('custom_')
329
- ? ['tools', 'custom']
330
- : problem.collection
331
- ? ['data', problem.collection, ...(problem.field ? ['schema', problem.field] : [])]
332
- : [],
478
+ : problem.code === 'grant_secrets_missing'
479
+ ? ['grants', 'host']
480
+ : problem.code === 'secret_name_taken'
481
+ ? ['secrets']
482
+ : problem.code.startsWith('grant_')
483
+ ? ['grants', 'collections']
484
+ : problem.code.startsWith('custom_')
485
+ ? ['tools', 'custom']
486
+ : problem.collection
487
+ ? ['data', problem.collection, ...(problem.field ? ['schema', problem.field] : [])]
488
+ : [],
333
489
  params: { code: problem.code },
334
490
  });
335
491
  }
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
@@ -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
+ }