@adminiumjs/manifest 0.3.41 → 0.3.43

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.
Files changed (46) hide show
  1. package/dist/automations.js +1 -1
  2. package/dist/automations.js.map +1 -1
  3. package/dist/change-guards.js +1 -1
  4. package/dist/change-guards.js.map +1 -1
  5. package/dist/check.d.ts +81 -0
  6. package/dist/check.d.ts.map +1 -0
  7. package/dist/check.js +130 -0
  8. package/dist/check.js.map +1 -0
  9. package/dist/expire.d.ts +46 -0
  10. package/dist/expire.d.ts.map +1 -0
  11. package/dist/expire.js +59 -0
  12. package/dist/expire.js.map +1 -0
  13. package/dist/formula.d.ts +6 -1
  14. package/dist/formula.d.ts.map +1 -1
  15. package/dist/formula.js +34 -6
  16. package/dist/formula.js.map +1 -1
  17. package/dist/index.d.ts +5 -3
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +5 -3
  20. package/dist/index.js.map +1 -1
  21. package/dist/outbox.d.ts.map +1 -1
  22. package/dist/outbox.js +5 -4
  23. package/dist/outbox.js.map +1 -1
  24. package/dist/people.d.ts +45 -4
  25. package/dist/people.d.ts.map +1 -1
  26. package/dist/people.js +74 -8
  27. package/dist/people.js.map +1 -1
  28. package/dist/public-access.d.ts +14 -0
  29. package/dist/public-access.d.ts.map +1 -1
  30. package/dist/public-access.js +183 -6
  31. package/dist/public-access.js.map +1 -1
  32. package/dist/roles.d.ts +4 -0
  33. package/dist/roles.d.ts.map +1 -1
  34. package/dist/roles.js +5 -0
  35. package/dist/roles.js.map +1 -1
  36. package/dist/schema.d.ts +808 -5
  37. package/dist/schema.d.ts.map +1 -1
  38. package/dist/schema.js +456 -20
  39. package/dist/schema.js.map +1 -1
  40. package/dist/validate.js +1 -1
  41. package/dist/validate.js.map +1 -1
  42. package/dist/words.d.ts +3 -1
  43. package/dist/words.d.ts.map +1 -1
  44. package/dist/words.js +130 -0
  45. package/dist/words.js.map +1 -1
  46. package/package.json +2 -2
package/dist/schema.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * The install PLANNER (planInstall / requiredSchema → create-or-map diff) and
9
9
  * the server-side installer are later layers that consume a validated manifest.
10
10
  */
11
- import { addOnBlockSchema, addOnCategorySchema, i18nMessageSchema, isSlotId, recordTabIssues, } from '@adminium/add-on-contracts';
11
+ import { addOnBlockSchema, addOnCategorySchema, i18nMessageSchema, isSlotId, recordPanelIssues, recordTabIssues, } from '@adminium/add-on-contracts';
12
12
  import { z } from 'zod';
13
13
  import { addOnNeedsIssues, addOnsSchema, requiresAddOn } from './add-ons.js';
14
14
  import { NAV_LINK_SCREENS, RECORD_ADDRESS_MESSAGE, SIDE_PATH_MESSAGE, isRecordAddress, isSidePath, railIssues } from './rail.js';
@@ -36,9 +36,11 @@ import { stepsIssues } from './steps.js';
36
36
  import { WORD_FLOORS, installFloorWords } from './words.js';
37
37
  import { mailClassFloorIssues } from './mail-class.js';
38
38
  import { keepSchema, peopleIssues, peopleSchema, publicConsentIssues } from './people.js';
39
+ import { expireIssues, expireSchema } from './expire.js';
39
40
  import { automationIssues, manifestAutomationsSchema } from './automations.js';
40
41
  import { changeGuardIssues, changeGuardsSchema } from './change-guards.js';
41
42
  import { adjustDecidedColumns, adjustIssues, adjustSchema, adjusterIssues, adjusterSchema } from './adjust.js';
43
+ import { checkIssues, checkSchema, checkerIssues, checkerSchema } from './check.js';
42
44
  import { ledgerIssues, ledgersSchema, postingIssues, postingsSchema, receiptTableIssues, wordsIssues, wordsListSchema } from './ledgers.js';
43
45
  import { addOnFormsSchema, formsBlockIssues } from './forms.js';
44
46
  export { compareSemver };
@@ -220,11 +222,46 @@ export const COLUMN_SEMANTICS = [
220
222
  /**
221
223
  * The kinds of file a file column may take (`rules.file.accept`), by the
222
224
  * names the server's content check gives them: what a file's bytes are, never
223
- * what its name or its sender says. Two of the server's kinds are never taken
224
- * by a column: SVG is a document that can carry script, and a HEIC picture is
225
- * one whose camera notes (the place, the device) cannot be taken out of it.
225
+ * what its name or its sender says. A HEIC picture is never taken by a column:
226
+ * its camera notes (the place, the device) cannot be taken out of it. An SVG
227
+ * is taken only as the server writes it again from its own tree, without
228
+ * anything that could run (`files/svg.ts`); a model (GLB, STL, 3MF) and a web
229
+ * font (WOFF2) only when their whole bytes are what they say (`files/models.ts`).
226
230
  */
227
- export const FILE_COLUMN_KINDS = ['png', 'jpeg', 'webp', 'gif', 'pdf', 'office', 'zip', 'csv', 'text', 'markdown', 'json', 'mp4', 'webm', 'mp3', 'wav', 'ogg'];
231
+ export const FILE_COLUMN_KINDS = ['png', 'jpeg', 'webp', 'gif', 'pdf', 'office', 'zip', 'csv', 'text', 'markdown', 'json', 'mp4', 'webm', 'mp3', 'wav', 'ogg', 'tiff', 'svg', 'glb', 'stl', '3mf', 'woff2'];
232
+ /**
233
+ * The kinds of file a guest may upload into a file column (`rules.file.upload`),
234
+ * by the words a form's file question uses, each with the column kind
235
+ * (`accept`) it is: pictures, PDF, plain text, TIFF (a print shop's artwork,
236
+ * kept and handed back as a download, never drawn on a page) and SVG (a
237
+ * customer's logo, kept only as the server writes it again, and never served
238
+ * to anyone but staff and the guest who sent it). The column's `accept` names
239
+ * each kind its upload takes. Models and fonts are no guest's: customers send
240
+ * logos, photos and finished print files, and the shop makes its own assets.
241
+ */
242
+ export const FILE_UPLOAD_KINDS = { jpg: 'jpeg', png: 'png', pdf: 'pdf', text: 'text', tiff: 'tiff', svg: 'svg' };
243
+ /**
244
+ * The kinds a file column's `accept` names that only a newer Adminium takes
245
+ * (`file.assetKinds` in `words.ts`): a cleaned SVG, a model, a web font.
246
+ */
247
+ export const ASSET_FILE_KINDS = ['svg', 'glb', 'stl', '3mf', 'woff2'];
248
+ /**
249
+ * The largest product asset served to every visitor (an entry's `assets`,
250
+ * through `GET /public/assets`), by its kind: a model 30 MiB, a font or an SVG
251
+ * piece 2 MiB. A column an entry shows as assets takes no larger file of that
252
+ * kind, so an asset staff could save is one visitors load.
253
+ */
254
+ export const PUBLIC_ASSET_MAX_BYTES = {
255
+ glb: 30 * 1024 * 1024,
256
+ stl: 30 * 1024 * 1024,
257
+ '3mf': 30 * 1024 * 1024,
258
+ woff2: 2 * 1024 * 1024,
259
+ svg: 2 * 1024 * 1024,
260
+ };
261
+ /** Whether a file rule takes product assets and nothing else. */
262
+ export function takesAssetsOnly(accept) {
263
+ return accept.length > 0 && accept.every((kind) => ASSET_FILE_KINDS.includes(kind));
264
+ }
228
265
  /**
229
266
  * The kinds of file a PICTURE column takes: a text column with `semantic:
230
267
  * "image"` and a file rule (`rules.file`) whose `accept` names only these.
@@ -235,8 +272,12 @@ export const PICTURE_FILE_KINDS = ['png', 'jpeg', 'webp', 'gif'];
235
272
  export function takesPicturesOnly(accept) {
236
273
  return accept.length > 0 && accept.every((kind) => PICTURE_FILE_KINDS.includes(kind));
237
274
  }
238
- /** The most a file column's `maxBytes` may say: 25 MiB. */
239
- export const FILE_COLUMN_MAX_BYTES = 25 * 1024 * 1024;
275
+ /**
276
+ * The most a file column's `maxBytes` may say: 100 MiB (a print shop's
277
+ * artwork PDF). The install's own ceiling (`files.maxBytes`) still holds every
278
+ * upload, and the owner may set it lower.
279
+ */
280
+ export const FILE_COLUMN_MAX_BYTES = 100 * 1024 * 1024;
240
281
  /**
241
282
  * The largest picture served to every visitor (an entry's `pictures`, through
242
283
  * `GET /public/pictures`): 5 MiB. A picture column shown to visitors takes no
@@ -280,6 +321,29 @@ const addMinutesSchema = z
280
321
  const deadlineSchema = z
281
322
  .object({ days: stampAmount(MOMENT_LIMITS.days), time: clockTimeSchema, notAfter: momentSchema.optional() })
282
323
  .strict();
324
+ /** The days of the week, as a working week names them. */
325
+ export const WORKING_WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
326
+ /** A time of day on a 24-hour clock, `HH:MM`. */
327
+ const cutoffTimeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'a time of day, HH:MM on a 24-hour clock');
328
+ /**
329
+ * `addWorkingDays`: the `days`-th working day after the day an order is
330
+ * RECEIVED — the day of `from` (`now`, or a date or moment of the row) on
331
+ * the venue's clock when that is a working day and `from` is before the
332
+ * cut-off (or has no time, or there is no cut-off); otherwise the next
333
+ * working day. A working day is one of `weekdays` that no active row of
334
+ * `closures` covers. `weekdays` and `cutoff` are stated, or name a `string`
335
+ * setting of the app (a shop changes its week and its cut-off itself).
336
+ */
337
+ const addWorkingDaysSchema = z
338
+ .object({
339
+ from: z.union([z.literal('now'), refSchema]),
340
+ days: z.union([refSchema, z.number().int().min(0).max(3650)]),
341
+ map: z.record(z.string().min(1), z.number().int().min(0).max(3650)).optional(),
342
+ weekdays: z.union([refSchema, z.array(z.enum(WORKING_WEEKDAYS)).min(1).max(7)]),
343
+ cutoff: z.union([cutoffTimeSchema, refSchema]).optional(),
344
+ closures: z.object({ table: refSchema, from: refSchema, to: refSchema, active: refSchema.optional() }).strict().optional(),
345
+ })
346
+ .strict();
283
347
  /**
284
348
  * What a stamp writes:
285
349
  *
@@ -294,6 +358,10 @@ const deadlineSchema = z
294
358
  * name) on a public write; on a staff write, `staff` says what instead;
295
359
  * - `addDays`: a date so many days after another (`due_on` = `issued_on` +
296
360
  * the terms' days, `map` giving each term its days);
361
+ * - `addWorkingDays`: a date so many WORKING days after a moment on the
362
+ * venue's clock (`ready_by` = the order's time + 3 working days): the
363
+ * working week and the daily cut-off stated, or named as the app's own
364
+ * settings; the days a closures table holds skipped;
297
365
  * - `hashOf`: a fingerprint — SHA-256 over the named columns, child rows and
298
366
  * a linked row's text, in a canonical form anyone can recompute;
299
367
  * - `addMinutes`: the moment so many minutes or hours from now (a hold for
@@ -348,6 +416,7 @@ export const stampSetSchema = z.union([
348
416
  z.object({ addMinutes: addMinutesSchema }).strict(),
349
417
  z.object({ deadline: deadlineSchema }).strict(),
350
418
  z.object({ moment: momentSchema }).strict(),
419
+ z.object({ addWorkingDays: addWorkingDaysSchema }).strict(),
351
420
  ]);
352
421
  /**
353
422
  * When a stamp is written: on create, when a column changes to a value, when
@@ -607,6 +676,28 @@ export const columnRulesSchema = z
607
676
  })
608
677
  .strict()
609
678
  .optional(),
679
+ /**
680
+ * A foreign key filled with the band a number of this row falls in: of
681
+ * the rows of `table` (within `scope`) whose `min` is at most `value`,
682
+ * the one with the greatest `min` — a quantity's price break, an order's
683
+ * delivery band by its weight. None: the link is empty (a product with
684
+ * no breaks). Past the picked row's `max` (when it has one), `over`
685
+ * says what happens: `refuse` the write (the default), or leave the
686
+ * link `empty` (a parcel too heavy for any band, quoted by hand). Only
687
+ * Adminium writes the link. A `value` that is a total of child rows, or
688
+ * a formula, is picked again whenever the total settles.
689
+ */
690
+ band: z
691
+ .object({
692
+ table: refSchema,
693
+ min: refSchema,
694
+ max: refSchema.optional(),
695
+ value: refSchema,
696
+ scope: z.array(z.object({ column: refSchema, equals: refSchema }).strict()).min(1).max(2).optional(),
697
+ over: z.enum(['refuse', 'empty']).optional(),
698
+ })
699
+ .strict()
700
+ .optional(),
610
701
  rollup: z
611
702
  .object({
612
703
  /** The child table, its foreign key back to this row, and what to add up. */
@@ -816,6 +907,23 @@ export const columnRulesSchema = z
816
907
  accept: z.array(z.enum(FILE_COLUMN_KINDS)).min(1).max(FILE_COLUMN_KINDS.length),
817
908
  maxBytes: z.number().int().min(1024).max(FILE_COLUMN_MAX_BYTES),
818
909
  maxCount: z.number().int().min(1).max(FILE_COLUMN_MAX_COUNT).optional(),
910
+ /**
911
+ * What a GUEST may upload into the column, through a public entry that
912
+ * lists it in `uploads`: the kinds (pictures, PDF, plain text, TIFF, SVG;
913
+ * each one the column's `accept` names), the most bytes of one (at most
914
+ * the column's `maxBytes`) and, in a list, how many (at most the
915
+ * column's `maxCount`). The file is kept unattached for a day; the
916
+ * guest's next write through the entry names it, and it becomes the
917
+ * row's file as a staff upload is.
918
+ */
919
+ upload: z
920
+ .object({
921
+ kinds: z.array(z.enum(Object.keys(FILE_UPLOAD_KINDS))).min(1).max(Object.keys(FILE_UPLOAD_KINDS).length),
922
+ maxBytes: z.number().int().min(1024).max(FILE_COLUMN_MAX_BYTES),
923
+ maxCount: z.number().int().min(1).max(FILE_COLUMN_MAX_COUNT).optional(),
924
+ })
925
+ .strict()
926
+ .optional(),
819
927
  })
820
928
  .strict()
821
929
  .optional(),
@@ -1031,6 +1139,8 @@ export const requiredTableSchema = z
1031
1139
  postings: postingsSchema.optional(),
1032
1140
  /** Where an add-on's offers and codes lower this order's price (see `adjust.ts`). */
1033
1141
  adjust: adjustSchema.optional(),
1142
+ /** Whether an order line's design may be ordered, asked of an add-on (see `check.ts`). */
1143
+ check: checkSchema.optional(),
1034
1144
  /** How a person belongs to a row, so a role's rows can be limited to their own or their team's (see `roles.ts`). */
1035
1145
  belongs: belongsSchema.optional(),
1036
1146
  /** Rows read by the people they belong to and by nobody and nothing else: no rule, public entry, document or export. */
@@ -1041,6 +1151,8 @@ export const requiredTableSchema = z
1041
1151
  removal: removalSchema.optional(),
1042
1152
  /** The law makes the business keep its rows (`{law: 'tax'}`): an erasure leaves them, and says why (see `people.ts`). */
1043
1153
  keep: keepSchema.optional(),
1154
+ /** An add-on's own rows let go some days after a moment of theirs, unless a column holds a value (see `expire.ts`). */
1155
+ expire: expireSchema.optional(),
1044
1156
  })
1045
1157
  .strict()
1046
1158
  .refine((t) => new Set(t.columns.map((c) => c.ref)).size === t.columns.length, { message: 'duplicate column ref in table', path: ['columns'] })
@@ -1069,6 +1181,8 @@ export const requiredTableSchema = z
1069
1181
  ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
1070
1182
  for (const issue of indexSetIssues(t))
1071
1183
  ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
1184
+ for (const issue of expireIssues(t))
1185
+ ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
1072
1186
  });
1073
1187
  /**
1074
1188
  * Everything wrong with a table's declared indexes: a column it lacks, a
@@ -2174,7 +2288,7 @@ sink) {
2174
2288
  if (rules === undefined)
2175
2289
  return;
2176
2290
  const here = (...rest) => at('columns', c, 'rules', ...rest);
2177
- const deciders = ['copy', 'sequence', 'code', 'rollup', 'stamp', 'formula', 'format', 'default', 'lookup', 'perNight'].filter((name) => rules[name] !== undefined);
2291
+ const deciders = ['copy', 'sequence', 'code', 'rollup', 'stamp', 'formula', 'format', 'default', 'lookup', 'band', 'perNight'].filter((name) => rules[name] !== undefined);
2178
2292
  if (deciders.length > 0)
2179
2293
  decide(table.ref, column.ref);
2180
2294
  // One rule decides a column; two would race for its value. A copy with
@@ -2574,6 +2688,9 @@ sink) {
2574
2688
  }
2575
2689
  }
2576
2690
  }
2691
+ else if (typeof set === 'object' && 'addWorkingDays' in set) {
2692
+ out.push(...workingDaysIssues(table, column, set.addWorkingDays, here('stamp', 'set', 'addWorkingDays'), index, m.settings ?? []));
2693
+ }
2577
2694
  else if (typeof set === 'object' && 'hashOf' in set) {
2578
2695
  const h = set.hashOf;
2579
2696
  if (column.type !== 'text' || (column.maxLength !== undefined && column.maxLength < 64)) {
@@ -2688,7 +2805,7 @@ sink) {
2688
2805
  out.push({ path: [...path, 'filled'], message: `"${table.ref}.${watched.ref}" is never empty, so it is never first filled` });
2689
2806
  }
2690
2807
  });
2691
- const others = ['copy', 'sequence', 'code', 'rollup', 'formula', 'format', 'default', 'lookup', 'perNight'].filter((name) => rules[name] !== undefined);
2808
+ const others = ['copy', 'sequence', 'code', 'rollup', 'formula', 'format', 'default', 'lookup', 'band', 'perNight'].filter((name) => rules[name] !== undefined);
2692
2809
  if (others.length > 0)
2693
2810
  out.push({ path: here('stamp'), message: `a stamped column is not also decided by ${others.join(', ')}` });
2694
2811
  if (stamp.clearOnBack === true)
@@ -2702,12 +2819,14 @@ sink) {
2702
2819
  if (rules.lookup !== undefined) {
2703
2820
  out.push(...codeLookupIssues(table, column, rules.lookup, here('lookup'), { index, tables, shareCodes: (ref) => shareCodeColumns(m.publicAccess ?? [], ref, index) }));
2704
2821
  }
2822
+ if (rules.band !== undefined)
2823
+ out.push(...bandIssues(table, column, rules.band, here('band'), tables));
2705
2824
  if (rules.addOnLink !== undefined) {
2706
2825
  const link = rules.addOnLink;
2707
2826
  if (!['int', 'bigint', 'text'].includes(column.type) || column.nullable !== true || column.references !== undefined) {
2708
2827
  out.push({ path: here('addOnLink'), message: `a link into an add-on is a nullable int, bigint or text column with no "references": the add-on may not be there` });
2709
2828
  }
2710
- const others = ['copy', 'sequence', 'code', 'rollup', 'stamp', 'formula', 'format', 'perNight'].filter((name) => rules[name] !== undefined);
2829
+ const others = ['copy', 'sequence', 'code', 'rollup', 'stamp', 'formula', 'format', 'band', 'perNight'].filter((name) => rules[name] !== undefined);
2711
2830
  if (others.length > 0)
2712
2831
  out.push({ path: here('addOnLink'), message: `a link into an add-on is filled by a person, a lookup or the settings row, not by ${others.join(', ')}` });
2713
2832
  if (rules.default !== undefined) {
@@ -2790,6 +2909,25 @@ sink) {
2790
2909
  }
2791
2910
  if (new Set(rules.file.accept).size !== rules.file.accept.length)
2792
2911
  out.push({ path: here('file', 'accept'), message: 'each kind of file is named once' });
2912
+ // What a guest uploads is a file the column takes, no larger, and no more of them than it holds.
2913
+ const upload = rules.file.upload;
2914
+ if (upload !== undefined) {
2915
+ if (new Set(upload.kinds).size !== upload.kinds.length)
2916
+ out.push({ path: here('file', 'upload', 'kinds'), message: 'each kind of file is named once' });
2917
+ for (const kind of upload.kinds) {
2918
+ const as = FILE_UPLOAD_KINDS[kind];
2919
+ if (!rules.file.accept.includes(as))
2920
+ out.push({ path: here('file', 'upload', 'kinds'), message: `a guest's "${kind}" file is one the column takes: add "${as}" to accept` });
2921
+ }
2922
+ if (upload.maxBytes > rules.file.maxBytes)
2923
+ out.push({ path: here('file', 'upload', 'maxBytes'), message: `a guest's file is no larger than the column takes (${String(rules.file.maxBytes)} bytes)` });
2924
+ if (upload.maxCount !== undefined) {
2925
+ if (rules.file.maxCount === undefined)
2926
+ out.push({ path: here('file', 'upload', 'maxCount'), message: 'the column holds one file, so a guest uploads one' });
2927
+ else if (upload.maxCount > rules.file.maxCount)
2928
+ out.push({ path: here('file', 'upload', 'maxCount'), message: `a guest uploads no more files than the column holds (${String(rules.file.maxCount)})` });
2929
+ }
2930
+ }
2793
2931
  // An image column that holds files holds pictures.
2794
2932
  if (column.semantic === 'image' && !takesPicturesOnly(rules.file.accept)) {
2795
2933
  out.push({ path: here('file', 'accept'), message: `"${table.ref}.${column.ref}" is an image column, so its files are pictures: ${PICTURE_FILE_KINDS.join(', ')}` });
@@ -2830,7 +2968,7 @@ sink) {
2830
2968
  out.push({ path: here('customerKey', 'of'), message: `"${table.ref}" has no column "${rules.customerKey.of}"` });
2831
2969
  else if (of.type !== 'text' || of.ref === column.ref)
2832
2970
  out.push({ path: here('customerKey', 'of'), message: `"${table.ref}.${of.ref}" is not a text column holding an address` });
2833
- const others = ['copy', 'sequence', 'code', 'rollup', 'stamp', 'formula', 'format', 'default', 'lookup', 'perNight'].filter((name) => rules[name] !== undefined);
2971
+ const others = ['copy', 'sequence', 'code', 'rollup', 'stamp', 'formula', 'format', 'default', 'lookup', 'band', 'perNight'].filter((name) => rules[name] !== undefined);
2834
2972
  if (others.length > 0)
2835
2973
  out.push({ path: here('customerKey'), message: `a customer key is worked out from its address, not also decided by ${others.join(', ')}` });
2836
2974
  decide(table.ref, column.ref);
@@ -2850,7 +2988,7 @@ sink) {
2850
2988
  else if (linkCode(table.ref, of.ref))
2851
2989
  out.push({ path: here('codeLast4', 'of'), message: `"${table.ref}.${of.ref}" is the code a shared link opens its row with, so no column keeps its last four` });
2852
2990
  }
2853
- const others = ['copy', 'sequence', 'code', 'rollup', 'stamp', 'formula', 'format', 'default', 'lookup', 'perNight'].filter((name) => rules[name] !== undefined);
2991
+ const others = ['copy', 'sequence', 'code', 'rollup', 'stamp', 'formula', 'format', 'default', 'lookup', 'band', 'perNight'].filter((name) => rules[name] !== undefined);
2854
2992
  if (others.length > 0)
2855
2993
  out.push({ path: here('codeLast4'), message: `the last four of a code are copied from it, not also decided by ${others.join(', ')}` });
2856
2994
  decide(table.ref, column.ref);
@@ -2881,6 +3019,15 @@ sink) {
2881
3019
  publicWritable: (of) => new Set((m.publicAccess ?? []).filter((entry) => entry.table === of).flatMap((entry) => entry.writable ?? [])),
2882
3020
  }, at));
2883
3021
  }
3022
+ if (table.check !== undefined) {
3023
+ out.push(...checkIssues(table, table.check, {
3024
+ key: shapeOf?.addOn ?? m.key,
3025
+ kind: shapeOf !== undefined || m.kind === 'add-on' ? 'add-on' : 'app',
3026
+ tables: tables,
3027
+ named: new Set([...(m.addOns?.requires ?? []), ...(m.addOns?.suggests ?? [])].map((need) => need.key)),
3028
+ features: new Map((m.addOns?.features ?? []).map((feature) => [feature.id, feature.requires])),
3029
+ }, at));
3030
+ }
2884
3031
  if (table.postings !== undefined) {
2885
3032
  // What a posting makes Adminium's own: how long a hold lasts, and an amount its ledger decides — on the row, or on the parent its lines belong to.
2886
3033
  for (const [p, posting] of table.postings.entries()) {
@@ -2985,6 +3132,7 @@ sink) {
2985
3132
  decided: (table) => decided.get(table) ?? new Set(),
2986
3133
  answersAvailability: (table) => tables.get(table)?.capacity !== undefined || tables.get(table)?.booking !== undefined,
2987
3134
  addOns: new Set([...(m.kind === 'add-on' ? [m.key] : []), ...[...(m.addOns?.requires ?? []), ...(m.addOns?.suggests ?? [])].map((need) => need.key)]),
3135
+ ridesAppKeys: m.kind === 'add-on',
2988
3136
  capacityOf: (table) => tables.get(table)?.capacity,
2989
3137
  figures: figureColumns(m.requiredSchema.tables),
2990
3138
  mailsOnCreate: (table) => (m.outbox?.producers ?? []).some((producer) => 'onCreate' in producer && producer.onCreate.table === table),
@@ -3269,7 +3417,7 @@ function slotPartyIssues(m) {
3269
3417
  return out;
3270
3418
  }
3271
3419
  /** The rules through which Adminium decides a column: nobody else writes it. */
3272
- const DECIDING_RULES = ['copy', 'sequence', 'code', 'rollup', 'stamp', 'formula', 'format', 'default', 'lookup'];
3420
+ const DECIDING_RULES = ['copy', 'sequence', 'code', 'rollup', 'stamp', 'formula', 'format', 'default', 'lookup', 'band'];
3273
3421
  const decidedByRules = (rules) => DECIDING_RULES.some((name) => rules?.[name] !== undefined);
3274
3422
  /**
3275
3423
  * A stamp emptied by an undo watches the table's state column, is a column
@@ -3425,6 +3573,176 @@ function codeLookupIssues(table, column, lookup, path, ctx) {
3425
3573
  });
3426
3574
  return out;
3427
3575
  }
3576
+ /**
3577
+ * A link filled with the band a number falls in: a nullable foreign key to
3578
+ * the band table; its lower and upper bounds numbers of that table; the
3579
+ * number placed one of this table's; each scope a pair of columns that hold
3580
+ * the same thing on both sides. And no circle: the number may not read,
3581
+ * through formulas, totals or copies, what is copied through the band link —
3582
+ * the band would then decide itself.
3583
+ */
3584
+ function bandIssues(table, column, band, path, tables) {
3585
+ const out = [];
3586
+ if (column.type !== 'fk' || column.nullable !== true || column.references !== band.table) {
3587
+ out.push({ path: [...path, 'table'], message: `a band fills this table's link to "${band.table}": the column is a nullable foreign key to it` });
3588
+ }
3589
+ const bands = tables.get(band.table);
3590
+ if (bands === undefined) {
3591
+ out.push({ path: [...path, 'table'], message: `"${band.table}" is not a table of this app` });
3592
+ }
3593
+ else {
3594
+ for (const side of ['min', 'max']) {
3595
+ const ref = band[side];
3596
+ if (ref === undefined)
3597
+ continue;
3598
+ const found = bands.columns.find((c) => c.ref === ref);
3599
+ if (found === undefined)
3600
+ out.push({ path: [...path, side], message: `"${band.table}" has no column "${ref}"` });
3601
+ else if (!NUMERIC_TYPES.includes(found.type))
3602
+ out.push({ path: [...path, side], message: `"${band.table}.${ref}" is not a number, so no value is placed against it` });
3603
+ }
3604
+ if (band.max !== undefined && band.max === band.min)
3605
+ out.push({ path: [...path, 'max'], message: 'a band reads its lower and its upper bound from two different columns' });
3606
+ }
3607
+ const placed = table.columns.find((c) => c.ref === band.value);
3608
+ if (placed === undefined)
3609
+ out.push({ path: [...path, 'value'], message: `"${table.ref}" has no column "${band.value}"` });
3610
+ else if (!NUMERIC_TYPES.includes(placed.type))
3611
+ out.push({ path: [...path, 'value'], message: `"${table.ref}.${band.value}" is not a number, so it falls in no band` });
3612
+ (band.scope ?? []).forEach((scope, k) => {
3613
+ const at = [...path, 'scope', k];
3614
+ const theirs = bands?.columns.find((c) => c.ref === scope.column);
3615
+ const ours = table.columns.find((c) => c.ref === scope.equals);
3616
+ if (bands !== undefined && theirs === undefined)
3617
+ out.push({ path: [...at, 'column'], message: `"${band.table}" has no column "${scope.column}"` });
3618
+ if (ours === undefined)
3619
+ out.push({ path: [...at, 'equals'], message: `"${table.ref}" has no column "${scope.equals}"` });
3620
+ else if (ours.ref === column.ref)
3621
+ out.push({ path: [...at, 'equals'], message: 'a band is scoped by another column than its own link' });
3622
+ if (theirs !== undefined && ours !== undefined && (theirs.type !== ours.type || theirs.references !== ours.references)) {
3623
+ out.push({ path: at, message: `"${table.ref}.${ours.ref}" and "${band.table}.${theirs.ref}" hold different things` });
3624
+ }
3625
+ });
3626
+ if (placed !== undefined && bandReadsItself(table.ref, column.ref, band.value, tables)) {
3627
+ out.push({ path: [...path, 'value'], message: `"${table.ref}.${band.value}" reads what is copied through "${column.ref}", so the band would decide itself` });
3628
+ }
3629
+ return out;
3630
+ }
3631
+ /**
3632
+ * Whether a band's number reads its own link: followed through the row's
3633
+ * formulas, the totals it adds up from child rows, the copies it takes
3634
+ * through a link and other bands, at most a few tables deep.
3635
+ */
3636
+ function bandReadsItself(tableRef, link, value, tables) {
3637
+ const seen = new Set();
3638
+ const reads = (t, c, depth) => {
3639
+ const key = `${t}.${c}`;
3640
+ if (seen.has(key) || depth > 16)
3641
+ return false;
3642
+ seen.add(key);
3643
+ if (t === tableRef && c === link)
3644
+ return true;
3645
+ const doc = tables.get(t);
3646
+ const col = doc?.columns.find((x) => x.ref === c);
3647
+ const rules = col?.rules;
3648
+ if (doc === undefined || rules === undefined)
3649
+ return false;
3650
+ const next = [];
3651
+ if (rules.formula !== undefined)
3652
+ for (const name of formulaColumns(rules.formula))
3653
+ next.push([t, name]);
3654
+ if (rules.rollup !== undefined) {
3655
+ for (const name of [rules.rollup.sum, rules.rollup.times, rules.rollup.latest?.of])
3656
+ if (name !== undefined)
3657
+ next.push([rules.rollup.from, name]);
3658
+ }
3659
+ if (rules.copy !== undefined) {
3660
+ next.push([t, rules.copy.via]);
3661
+ const via = doc.columns.find((x) => x.ref === rules.copy.via)?.references;
3662
+ if (via !== undefined)
3663
+ next.push([via, rules.copy.from]);
3664
+ }
3665
+ if (rules.band !== undefined) {
3666
+ next.push([t, rules.band.value]);
3667
+ for (const scope of rules.band.scope ?? [])
3668
+ next.push([t, scope.equals]);
3669
+ }
3670
+ return next.some(([nt, nc]) => reads(nt, nc, depth + 1));
3671
+ };
3672
+ return reads(tableRef, value, 0);
3673
+ }
3674
+ /**
3675
+ * A date so many working days on: written into a date column, from `now` or
3676
+ * a date or moment of the row; its days a number, a whole-number column, or
3677
+ * a choice with each value's days; its week named days, none twice, or a
3678
+ * `string` setting the app declares, as its cut-off is; its closures a table
3679
+ * of the app with two dates (and a yes/no that switches a row off).
3680
+ */
3681
+ function workingDaysIssues(table, column, w, path, index, settings) {
3682
+ const out = [];
3683
+ if (column.type !== 'date')
3684
+ out.push({ path, message: 'a date so many working days on needs a date column' });
3685
+ if (w.from !== 'now') {
3686
+ const from = index.column(table.ref, w.from);
3687
+ if (from === undefined)
3688
+ out.push({ path: [...path, 'from'], message: `"${table.ref}" has no column "${w.from}"` });
3689
+ else if (from.type !== 'date' && from.type !== 'timestamptz')
3690
+ out.push({ path: [...path, 'from'], message: `"${table.ref}.${w.from}" is neither a date nor a moment` });
3691
+ else if (from.ref === column.ref)
3692
+ out.push({ path: [...path, 'from'], message: 'working days are counted from another column' });
3693
+ }
3694
+ if (typeof w.days === 'string') {
3695
+ const days = index.column(table.ref, w.days);
3696
+ if (days === undefined) {
3697
+ out.push({ path: [...path, 'days'], message: `"${table.ref}" has no column "${w.days}"` });
3698
+ }
3699
+ else if (days.type === 'enum' || days.type === 'text') {
3700
+ const missing = (days.enum ?? []).filter((value) => w.map?.[value] === undefined);
3701
+ if (w.map === undefined || missing.length > 0) {
3702
+ out.push({ path: [...path, 'map'], message: `each value of "${table.ref}.${w.days}" needs its days${missing.length > 0 ? ` (${missing.join(', ')})` : ''}` });
3703
+ }
3704
+ }
3705
+ else if (days.type !== 'int' && days.type !== 'bigint') {
3706
+ out.push({ path: [...path, 'days'], message: `"${table.ref}.${w.days}" is neither a number of days nor a choice with a map` });
3707
+ }
3708
+ }
3709
+ const named = (key, at) => {
3710
+ const setting = settings.find((candidate) => candidate.key === key);
3711
+ if (setting === undefined)
3712
+ out.push({ path: [...path, at], message: `the app declares no setting "${key}"` });
3713
+ else if (setting.type !== 'string')
3714
+ out.push({ path: [...path, at], message: `the setting "${key}" is a ${setting.type}: a working week or a cut-off is kept in a string setting` });
3715
+ };
3716
+ if (typeof w.weekdays === 'string')
3717
+ named(w.weekdays, 'weekdays');
3718
+ else if (new Set(w.weekdays).size !== w.weekdays.length)
3719
+ out.push({ path: [...path, 'weekdays'], message: 'a working week names each day once' });
3720
+ if (w.cutoff !== undefined && !/^\d/.test(w.cutoff))
3721
+ named(w.cutoff, 'cutoff');
3722
+ if (w.closures !== undefined) {
3723
+ const c = w.closures;
3724
+ if (index.table(c.table) === undefined) {
3725
+ out.push({ path: [...path, 'closures', 'table'], message: `"${c.table}" is not a table of this app` });
3726
+ }
3727
+ else {
3728
+ for (const side of ['from', 'to']) {
3729
+ const found = index.column(c.table, c[side]);
3730
+ if (found === undefined)
3731
+ out.push({ path: [...path, 'closures', side], message: `"${c.table}" has no column "${c[side]}"` });
3732
+ else if (found.type !== 'date')
3733
+ out.push({ path: [...path, 'closures', side], message: `"${c.table}.${c[side]}" is not a date` });
3734
+ }
3735
+ if (c.active !== undefined) {
3736
+ const active = index.column(c.table, c.active);
3737
+ if (active === undefined)
3738
+ out.push({ path: [...path, 'closures', 'active'], message: `"${c.table}" has no column "${c.active}"` });
3739
+ else if (active.type !== 'bool')
3740
+ out.push({ path: [...path, 'closures', 'active'], message: `"${c.table}.${c.active}" is not a yes/no` });
3741
+ }
3742
+ }
3743
+ }
3744
+ return out;
3745
+ }
3428
3746
  /**
3429
3747
  * The version of Adminium that first installs an add-on the way it installs
3430
3748
  * an app: with pages, roles, rules on its own tables and the rest of
@@ -3433,6 +3751,53 @@ function codeLookupIssues(table, column, lookup, path, ctx) {
3433
3751
  * answers "needs a newer Adminium" and never "unrecognized key".
3434
3752
  */
3435
3753
  export const ADD_ON_INSTALL_FLOOR = '0.3.18';
3754
+ /**
3755
+ * The version of Adminium from which an add-on names only its own tables in
3756
+ * a foreign key's `references`. Before it, an add-on could point a foreign
3757
+ * key at a table of the database it was put on (`products`), which the
3758
+ * installer found there or made empty beside it; an app now binds an add-on
3759
+ * from its own side instead (`rules.addOnLink` on the app's column, filled
3760
+ * by a `lookup` where a guest types a code). A manifest declaring this floor
3761
+ * or a later one is held to it; one declaring an older floor installs as it
3762
+ * always did.
3763
+ */
3764
+ export const HOST_REFERENCES_FLOOR = '0.3.43';
3765
+ /**
3766
+ * The words only an add-on says of its own tables: rows that expire, and an
3767
+ * entry held to the app whose key serves it. An app's rows are its owner's to
3768
+ * keep, and its entries serve its own key only.
3769
+ */
3770
+ function addOnOnlyWordIssues(m) {
3771
+ const out = [];
3772
+ m.requiredSchema.tables.forEach((table, t) => {
3773
+ if (table.expire !== undefined)
3774
+ out.push({ path: ['requiredSchema', 'tables', t, 'expire'], message: "only an add-on's own table expires: an app's rows are its owner's to keep" });
3775
+ });
3776
+ (m.publicAccess ?? []).forEach((entry, e) => {
3777
+ if (entry.appColumn !== undefined)
3778
+ out.push({ path: ['publicAccess', e, 'appColumn'], message: "an app's entry serves its own key and its own rows: appColumn is said by an add-on's entry" });
3779
+ });
3780
+ return out;
3781
+ }
3782
+ /** A foreign key of an add-on at or above {@link HOST_REFERENCES_FLOOR} that names a table the add-on does not declare. */
3783
+ function hostReferenceIssues(m) {
3784
+ if (compareSemver(m.compatibility.minAdminiumVersion, HOST_REFERENCES_FLOOR) < 0)
3785
+ return [];
3786
+ const tables = m.requiredSchema?.tables ?? [];
3787
+ const own = new Set(tables.map((table) => table.ref));
3788
+ const out = [];
3789
+ tables.forEach((table, t) => {
3790
+ table.columns.forEach((column, c) => {
3791
+ if (column.type !== 'fk' || column.references === undefined || own.has(column.references))
3792
+ return;
3793
+ out.push({
3794
+ path: ['requiredSchema', 'tables', t, 'columns', c, 'references'],
3795
+ message: `"${column.references}" is not a table of this add-on: an add-on's foreign key names one of its own tables. An app binds the add-on from its side, with rules.addOnLink on its own column (and a lookup where a code is typed)`,
3796
+ });
3797
+ });
3798
+ });
3799
+ return out;
3800
+ }
3436
3801
  /**
3437
3802
  * The blocks an app and an add-on declare in the same words: what Adminium
3438
3803
  * makes at install beside the tables. Both branches spread it, so a block
@@ -3512,6 +3877,8 @@ export const appManifestSchema = z
3512
3877
  ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
3513
3878
  for (const issue of publicConsentIssues(m, tableIndex(m.requiredSchema.tables)))
3514
3879
  ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
3880
+ for (const issue of addOnOnlyWordIssues(m))
3881
+ ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
3515
3882
  for (const issue of appReferenceIssues(m)) {
3516
3883
  ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
3517
3884
  }
@@ -3584,6 +3951,9 @@ export const addOnManifestSchema = z
3584
3951
  ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
3585
3952
  for (const issue of addOnModuleIssues(m))
3586
3953
  ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
3954
+ // From its floor on, an add-on's foreign keys name its own tables only; older manifests keep pointing at the database's.
3955
+ for (const issue of hostReferenceIssues(m))
3956
+ ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
3587
3957
  // Add-ons released before the floor keep the page refs they shipped with.
3588
3958
  if (m.pages !== undefined || compareSemver(m.compatibility.minAdminiumVersion, ADD_ON_INSTALL_FLOOR) >= 0) {
3589
3959
  for (const issue of addOnPageRefIssues(m))
@@ -3688,7 +4058,7 @@ export function installsLikeAnApp(m) {
3688
4058
  return false;
3689
4059
  if (ADD_ON_BLOCKS.some((block) => m[block] !== undefined))
3690
4060
  return true;
3691
- if (m.requiredSchema?.prefixed === true || m.addOn.settingsTable !== undefined || m.addOn.ledgers !== undefined || m.addOn.adjuster !== undefined || m.addOn.words !== undefined || m.addOn.recordTabs !== undefined || m.addOn.steps !== undefined || m.addOn.assistant !== undefined || m.addOn.forms !== undefined)
4061
+ if (m.requiredSchema?.prefixed === true || m.addOn.settingsTable !== undefined || m.addOn.ledgers !== undefined || m.addOn.adjuster !== undefined || m.addOn.checker !== undefined || m.addOn.words !== undefined || m.addOn.recordTabs !== undefined || m.addOn.steps !== undefined || m.addOn.assistant !== undefined || m.addOn.forms !== undefined)
3692
4062
  return true;
3693
4063
  return (m.requiredSchema?.tables ?? []).some((table) => table.states !== undefined ||
3694
4064
  table.capacity !== undefined ||
@@ -3826,6 +4196,13 @@ export function adjusterOf(m) {
3826
4196
  const parsed = adjusterSchema.safeParse(m.addOn.adjuster);
3827
4197
  return parsed.success ? parsed.data : null;
3828
4198
  }
4199
+ /** An add-on's checker, typed; null for an app, and for an add-on that declares none. */
4200
+ export function checkerOf(m) {
4201
+ if (m.kind !== 'add-on' || m.addOn.checker === undefined)
4202
+ return null;
4203
+ const parsed = checkerSchema.safeParse(m.addOn.checker);
4204
+ return parsed.success ? parsed.data : null;
4205
+ }
3829
4206
  /** An add-on's forms block, typed; null for an app, and for an add-on that declares none. */
3830
4207
  export function formsOf(m) {
3831
4208
  if (m.kind !== 'add-on' || m.addOn.forms === undefined)
@@ -3952,6 +4329,11 @@ function addOnInstallIssues(m) {
3952
4329
  if (m.addOn.recordTabs !== undefined) {
3953
4330
  out.push(...recordTabIssues({ tabs: m.addOn.recordTabs, tables: tables, words: wordsOf(m).map((words) => words.id) }));
3954
4331
  }
4332
+ // Its own screens on other tables' record pages: on its own tables, under its key, in files of their own.
4333
+ if (m.addOn.panels !== undefined) {
4334
+ const pages = [...(m.addOn.pages ?? []), ...(m.pages ?? []).map((page) => ({ ref: page.ref, client: '' }))];
4335
+ out.push(...recordPanelIssues({ key: m.key, panels: m.addOn.panels, tables, pages, slots: m.addOn.slots ?? [] }));
4336
+ }
3955
4337
  // The adjuster is typed loosely in the contracts package too: its words are checked here.
3956
4338
  if (m.addOn.adjuster !== undefined) {
3957
4339
  const parsed = adjusterSchema.safeParse(m.addOn.adjuster);
@@ -3966,6 +4348,20 @@ function addOnInstallIssues(m) {
3966
4348
  if (providers.length !== 1)
3967
4349
  out.push({ path: ['addOn', 'provides'], message: 'an add-on with an adjuster provides the contract "price-adjust" exactly once' });
3968
4350
  }
4351
+ // The checker is typed loosely in the contracts package too: its words are checked here.
4352
+ if (m.addOn.checker !== undefined) {
4353
+ const parsed = checkerSchema.safeParse(m.addOn.checker);
4354
+ if (!parsed.success) {
4355
+ for (const issue of parsed.error.issues)
4356
+ out.push({ path: ['addOn', 'checker', ...issue.path.map((part) => (typeof part === 'symbol' ? String(part) : part))], message: issue.message });
4357
+ }
4358
+ else {
4359
+ out.push(...checkerIssues(parsed.data, byRef));
4360
+ }
4361
+ const providers = (m.addOn.provides ?? []).filter((entry) => entry.contract === 'design-check');
4362
+ if (providers.length !== 1)
4363
+ out.push({ path: ['addOn', 'provides'], message: 'an add-on with a checker provides the contract "design-check" exactly once' });
4364
+ }
3969
4365
  // A ledger is served by the add-on's one `posting-rows` provider, and its receipt table is the one Adminium writes.
3970
4366
  if (m.addOn.ledgers !== undefined) {
3971
4367
  const providers = (m.addOn.provides ?? []).filter((entry) => entry.contract === 'posting-rows');
@@ -4038,18 +4434,57 @@ function addOnInstallIssues(m) {
4038
4434
  const key = (m.publicKeys ?? {})[linkKey];
4039
4435
  for (const field of ['requiresStaff', 'enabledBy', 'peak']) {
4040
4436
  if (key?.[field] !== undefined)
4041
- out.push({ path: ['publicKeys', linkKey, field], message: `an add-on's key opens one row by its link and only reads: it takes no "${field}"` });
4437
+ out.push({ path: ['publicKeys', linkKey, field], message: `an add-on's key opens one row by its link: it takes no "${field}"` });
4042
4438
  }
4043
4439
  }
4440
+ /*
4441
+ * The link key's own link (`own: true`): emailed to the row's own address
4442
+ * or handed once to whoever made the row, it may also change that row (a
4443
+ * shopper's saved draft), within what its entry names and only while the row
4444
+ * is in a state the entry names, and remove it (`DELETE`) on the same terms.
4445
+ * Any other link only reads.
4446
+ */
4447
+ const ownLink = (m.publicAccess ?? []).some((entry) => entry.key !== undefined && entry.key === linkKey && entry.claim !== undefined && 'by' in entry.claim && entry.claim.own === true);
4448
+ /** The columns of each table an entry holds to the app whose key serves it: never moved by a link. */
4449
+ const heldTo = new Map();
4450
+ for (const entry of m.publicAccess ?? [])
4451
+ if (entry.appColumn !== undefined)
4452
+ heldTo.set(entry.table, entry.appColumn);
4044
4453
  (m.publicAccess ?? []).forEach((entry, e) => {
4454
+ /*
4455
+ * A read for a session's holder alone, on each app's key: whoever the app
4456
+ * signed in (a guest who found their order) reads one row by its own code
4457
+ * (a design's code, which the order's lines hold) and nothing else. Never
4458
+ * a list: everyone the app signs in would read every row.
4459
+ */
4460
+ if (entry.key === undefined && entry.level !== undefined && entry.claim === undefined && entry.claimedBy === undefined && entry.visibleWith === undefined) {
4461
+ if (entry.unlockBy === undefined || !('self' in entry.unlockBy)) {
4462
+ out.push({ path: ['publicAccess', e, 'unlockBy'], message: "an add-on's read for whoever an app signed in opens one row by its own code: give it unlockBy {\"header\": true, \"column\": …, \"self\": true, \"length\": …}" });
4463
+ }
4464
+ }
4045
4465
  if (entry.key === undefined)
4046
4466
  return;
4047
4467
  if (entry.key !== linkKey) {
4048
4468
  out.push({ path: ['publicAccess', e, 'key'], message: `an add-on's entry is served through its app's key, or through the add-on's own link key${linkKey === undefined ? '' : ` "${linkKey}"`}: "${entry.key}" is neither` });
4049
4469
  return;
4050
4470
  }
4051
- if (entry.methods.some((method) => method !== 'GET'))
4052
- out.push({ path: ['publicAccess', e, 'methods'], message: `"${linkKey}" opens a row to whoever holds its link, so it only reads` });
4471
+ // A link that is not the row's own only reads: the key rules every manifest meets say so (`publicAccessIssues`).
4472
+ if (ownLink && entry.methods.some((method) => method !== 'GET' && method !== 'PATCH' && method !== 'DELETE')) {
4473
+ out.push({ path: ['publicAccess', e, 'methods'], message: `"${linkKey}" opens a row as its own link, which reads it, changes it and removes it, and makes no row` });
4474
+ }
4475
+ if (entry.methods.includes('PATCH') || entry.methods.includes('DELETE')) {
4476
+ // Changed or removed only while it is in a state it names: an ordered design is the order's, never the shopper's.
4477
+ const states = Object.values(entry.writableWhen ?? {}).some((when) => Array.isArray(when));
4478
+ const done = entry.methods.includes('PATCH') ? (entry.methods.includes('DELETE') ? 'changed or removed' : 'changed') : 'removed';
4479
+ if (!states)
4480
+ out.push({ path: ['publicAccess', e, 'writableWhen'], message: `a row ${done} through "${linkKey}" is ${done} only while it is in a state the entry names: give it writableWhen, e.g. {"state": ["draft"]}` });
4481
+ }
4482
+ if (entry.methods.includes('PATCH')) {
4483
+ const held = heldTo.get(entry.table);
4484
+ if (held !== undefined && ((entry.writable ?? []).includes(held) || Object.prototype.hasOwnProperty.call(entry.writableValues ?? {}, held))) {
4485
+ out.push({ path: ['publicAccess', e, 'writable'], message: `"${entry.table}.${held}" holds the app a row is for (appColumn), so no link moves it` });
4486
+ }
4487
+ }
4053
4488
  const claim = entry.claim;
4054
4489
  if (claim === undefined) {
4055
4490
  // Whoever holds a link reads that one row and what hangs under it: nothing on the key is open to everyone.
@@ -4058,8 +4493,8 @@ function addOnInstallIssues(m) {
4058
4493
  }
4059
4494
  return;
4060
4495
  }
4061
- if (!('by' in claim) || claim.by !== 'token' || claim.own === true) {
4062
- out.push({ path: ['publicAccess', e, 'claim'], message: 'an add-on\'s link key claims by token, and never as the row\'s own link that may change it' });
4496
+ if (!('by' in claim) || claim.by !== 'token') {
4497
+ out.push({ path: ['publicAccess', e, 'claim'], message: 'an add-on\'s link key claims by token' });
4063
4498
  return;
4064
4499
  }
4065
4500
  const token = byRef.get(entry.table)?.columns.find((column) => column.ref === claim.column);
@@ -4070,7 +4505,8 @@ function addOnInstallIssues(m) {
4070
4505
  });
4071
4506
  // Roles: what an add-on's role may open is its own tables, its own pages and its settings.
4072
4507
  const generated = new Set((m.pages ?? []).map((page) => page.ref));
4073
- const code = new Set((m.addOn.pages ?? []).map((page) => page.ref));
4508
+ // A panel is opened as one of its pages is: by `page:@<panel id>:view`.
4509
+ const code = new Set([...(m.addOn.pages ?? []).map((page) => page.ref), ...(m.addOn.panels ?? []).map((panel) => panel.id)]);
4074
4510
  const settingsOf = new Set([m.key, ...(m.addOns?.suggests ?? []).map((need) => need.key)]);
4075
4511
  (m.roles ?? []).forEach((role, r) => {
4076
4512
  if (role.screensOnly !== undefined)