@adminiumjs/manifest 0.3.5 → 0.3.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.
Files changed (66) hide show
  1. package/dist/capacity.d.ts +786 -0
  2. package/dist/capacity.d.ts.map +1 -0
  3. package/dist/capacity.js +717 -0
  4. package/dist/capacity.js.map +1 -0
  5. package/dist/documents.d.ts +91 -5
  6. package/dist/documents.d.ts.map +1 -1
  7. package/dist/documents.js +129 -32
  8. package/dist/documents.js.map +1 -1
  9. package/dist/formula.d.ts +114 -2
  10. package/dist/formula.d.ts.map +1 -1
  11. package/dist/formula.js +291 -5
  12. package/dist/formula.js.map +1 -1
  13. package/dist/index.d.ts +12 -6
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +12 -6
  16. package/dist/index.js.map +1 -1
  17. package/dist/marketplace-wire.d.ts +230 -0
  18. package/dist/marketplace-wire.d.ts.map +1 -0
  19. package/dist/marketplace-wire.js +138 -0
  20. package/dist/marketplace-wire.js.map +1 -0
  21. package/dist/outbox.d.ts +242 -27
  22. package/dist/outbox.d.ts.map +1 -1
  23. package/dist/outbox.js +553 -27
  24. package/dist/outbox.js.map +1 -1
  25. package/dist/plan-context.d.ts +28 -3
  26. package/dist/plan-context.d.ts.map +1 -1
  27. package/dist/plan-context.js +291 -14
  28. package/dist/plan-context.js.map +1 -1
  29. package/dist/plan-model.d.ts +58 -3
  30. package/dist/plan-model.d.ts.map +1 -1
  31. package/dist/public-access.d.ts +637 -0
  32. package/dist/public-access.d.ts.map +1 -1
  33. package/dist/public-access.js +1593 -12
  34. package/dist/public-access.js.map +1 -1
  35. package/dist/refs.d.ts +86 -0
  36. package/dist/refs.d.ts.map +1 -1
  37. package/dist/refs.js +186 -0
  38. package/dist/refs.js.map +1 -1
  39. package/dist/roles.d.ts +10 -2
  40. package/dist/roles.d.ts.map +1 -1
  41. package/dist/roles.js +27 -5
  42. package/dist/roles.js.map +1 -1
  43. package/dist/sample.d.ts +218 -2
  44. package/dist/sample.d.ts.map +1 -1
  45. package/dist/sample.js +145 -7
  46. package/dist/sample.js.map +1 -1
  47. package/dist/schema.d.ts +2860 -360
  48. package/dist/schema.d.ts.map +1 -1
  49. package/dist/schema.js +1002 -54
  50. package/dist/schema.js.map +1 -1
  51. package/dist/shapes.d.ts +5 -1
  52. package/dist/shapes.d.ts.map +1 -1
  53. package/dist/shapes.js +13 -3
  54. package/dist/shapes.js.map +1 -1
  55. package/dist/states.d.ts +342 -2
  56. package/dist/states.d.ts.map +1 -1
  57. package/dist/states.js +566 -7
  58. package/dist/states.js.map +1 -1
  59. package/dist/table-shapes.d.ts +47 -0
  60. package/dist/table-shapes.d.ts.map +1 -0
  61. package/dist/table-shapes.js +159 -0
  62. package/dist/table-shapes.js.map +1 -0
  63. package/dist/validate.d.ts.map +1 -1
  64. package/dist/validate.js +80 -2
  65. package/dist/validate.js.map +1 -1
  66. package/package.json +2 -2
@@ -27,7 +27,11 @@
27
27
  * entry reaches the parent row: a draft's lines stay as hidden as the draft.
28
28
  */
29
29
  import { z } from 'zod';
30
+ import { kindOf, rulesOf } from './capacity.js';
31
+ import { formulaColumns } from './formula.js';
30
32
  import { refSchema, scalarSchema, settingRefSchema, valueFits, } from './refs.js';
33
+ import { momentIssues, momentOffsetSchema, plainMomentSchema, wallTimeSchema } from './refs.js';
34
+ import { conditionIssues, linkedConditionSchema, reachedOnlyByUndo, stateConditionSchema } from './states.js';
31
35
  /** The key every entry uses unless it names another. */
32
36
  export const CUSTOMER_KEY = 'customer';
33
37
  const keyNameSchema = z.string().regex(/^[a-z][a-z0-9-]{0,31}$/, 'a key name is kebab-case');
@@ -68,9 +72,27 @@ const lookupClaimSchema = z
68
72
  });
69
73
  /** Signing in by a link emailed to the address in `email`. */
70
74
  const linkClaimSchema = z.object({ verify: z.literal('email-link'), email: refSchema }).strict();
71
- /** Opening one row by the code in `column`, while it has not expired or been stopped. */
75
+ /** The one or two columns of a row whose addresses its own link may go to. */
76
+ const ownAddressSchema = z.union([refSchema, z.tuple([refSchema, refSchema])]);
77
+ /**
78
+ * Opening one row by the code in `column`, while it has not expired or been
79
+ * stopped. `own`: the row's owner's own link — emailed only to the row's own
80
+ * address or handed once to whoever made the row — which opens a verified
81
+ * session and may change the row through the key's entries.
82
+ */
72
83
  const tokenClaimSchema = z
73
- .object({ by: z.literal('token'), column: refSchema, expires: refSchema.optional(), stopped: refSchema.optional() })
84
+ .object({
85
+ by: z.literal('token'),
86
+ column: refSchema,
87
+ expires: refSchema.optional(),
88
+ stopped: refSchema.optional(),
89
+ own: z.literal(true).optional(),
90
+ /**
91
+ * With `own`: the text columns of this row whose addresses its own link
92
+ * may be emailed to (the holder's, and the friend a ticket is offered to).
93
+ */
94
+ address: ownAddressSchema.optional(),
95
+ })
74
96
  .strict();
75
97
  export const claimSchema = z.union([lookupClaimSchema, linkClaimSchema, tokenClaimSchema]);
76
98
  /** How an identity entry claims: by details, by an emailed link, or by a token. */
@@ -97,12 +119,281 @@ export function shareCodeColumns(entries, table) {
97
119
  }
98
120
  return out;
99
121
  }
122
+ /**
123
+ * Why a column of another row may never be printed in a list one level down
124
+ * (an email's `joins`, a document's list of names): a list is read for
125
+ * whoever the email or document goes to, one row further from them than the
126
+ * row they asked for, so no reader decides what it shows. A secret, personal
127
+ * data, a code (the one a shared link opens its row with, one Adminium makes,
128
+ * one a person typed), or a column an entry withholds from all but its
129
+ * holder. Null for a column any reader may see listed.
130
+ */
131
+ export function unlistedColumn(m, table, column) {
132
+ const entries = m.publicAccess ?? [];
133
+ if (shareCodeColumns(entries, table).includes(column))
134
+ return 'the code a shared link opens its row with';
135
+ if (entries.some((entry) => entry.table === table && entry.withhold?.columns.includes(column) === true))
136
+ return 'withheld from all but its holder';
137
+ const columns = m.requiredSchema.tables.find((candidate) => candidate.ref === table)?.columns ?? [];
138
+ const rules = columns.find((candidate) => candidate.ref === column)?.rules;
139
+ if (rules?.['secret'] === true)
140
+ return 'a secret';
141
+ if (rules?.['personal'] === true)
142
+ return 'personal data';
143
+ if (rules?.['code'] !== undefined)
144
+ return 'a code';
145
+ if (columns.some((candidate) => candidate.rules?.['lookup']?.from === column))
146
+ return 'a code a person typed';
147
+ return null;
148
+ }
149
+ /*
150
+ * What the install guesses is personal data from a column's name, the way
151
+ * its classifier does on the table it makes — kept here so the validator
152
+ * refuses what the install would. A column's own `personal` wins either way.
153
+ */
154
+ const PERSONAL_TEXT_NAMES = [
155
+ /(^|_)e?mail(_address)?(_|$)/,
156
+ /(^|_)(phone|mobile|tele?phone|fax)(_number)?(_|$)/,
157
+ /(^|_)ip(_address)?$/,
158
+ /(^|_)(address|street|city|zip|postal_code|postcode)(_|$)/,
159
+ ];
160
+ const PERSONAL_NAMES = [
161
+ /(^|_)(ssn|social_security|tax_id|vat|passport|national_id|driver_licen[cs]e)(_|$)/,
162
+ /(^|_)(card_number|pan|iban|account_number|routing_number|bic|swift)(_|$)/,
163
+ /(^|_)(birth(date|day)?|dob|date_of_birth)(_|$)/,
164
+ ];
165
+ const PEOPLE_TABLE = /(^|_)(users?|people|persons?|employees?|staff|members?|contacts?|customers?|profiles?|teachers?|students?|drivers?|agents?|authors?|patients?)(_|$)/;
166
+ const PERSON_NAME = /^(first_name|last_name|full_name|display_name|name|username)$/;
167
+ /** `userId` → `user_id`: the form a name is matched in. */
168
+ function nameForm(name) {
169
+ return name
170
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
171
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
172
+ .replace(/[\s-]+/g, '_')
173
+ .toLowerCase();
174
+ }
175
+ /**
176
+ * Whether a column holds personal data: marked so (`personal: true`), or —
177
+ * unless marked otherwise — guessed so by its name as the install does: an
178
+ * address, a phone number, a birth date, an ID or account number, a person's
179
+ * first, last or full name on a table of people. Secrets are not personal
180
+ * data (they are never shown at all).
181
+ */
182
+ export function personalColumn(table, column) {
183
+ const found = table.columns.find((candidate) => candidate.ref === column);
184
+ if (found === undefined)
185
+ return null;
186
+ const personal = found.rules?.['personal'];
187
+ if (personal === true)
188
+ return 'marked';
189
+ if (personal === false || found.rules?.['secret'] === true)
190
+ return null;
191
+ const name = nameForm(column);
192
+ // Kept as text on every engine: an enum is a text column the install reads by its name as any other.
193
+ const text = found.type === 'text' || found.type === 'enum';
194
+ if (text && PERSONAL_TEXT_NAMES.some((pattern) => pattern.test(name)))
195
+ return 'guessed';
196
+ if (PERSONAL_NAMES.some((pattern) => pattern.test(name)))
197
+ return 'guessed';
198
+ if (text && /^(first_name|last_name|full_name)$/.test(name)) {
199
+ const names = table.columns.map((candidate) => nameForm(candidate.ref));
200
+ const hasEmail = table.columns.some((candidate, i) => (candidate.type === 'text' || candidate.type === 'enum') && PERSONAL_TEXT_NAMES[0].test(names[i]));
201
+ if (PEOPLE_TABLE.test(nameForm(table.ref)) || (hasEmail && names.some((n) => PERSON_NAME.test(n))))
202
+ return 'guessed';
203
+ }
204
+ return null;
205
+ }
100
206
  /**
101
207
  * A time no more than `within` minutes ahead — a past time always passes. A
102
208
  * kiosk takes an arrival from an hour before the visit, and a late one too.
103
209
  * Up to a day: a longer window is no window.
104
210
  */
105
211
  const timeWindowSchema = z.object({ within: z.number().int().min(1).max(1440) }).strict();
212
+ /**
213
+ * One end of a window read from a moment (see `refs.ts`). Keyed by a date or
214
+ * a time of the row, it is that column's moment and names no column; keyed
215
+ * by one of the row's links, `column` is the linked row's. `or` lists
216
+ * moments of this row read in turn when the first is empty.
217
+ */
218
+ const windowMomentSchema = z
219
+ .object({
220
+ column: refSchema.optional(),
221
+ time: wallTimeSchema.optional(),
222
+ plus: momentOffsetSchema.optional(),
223
+ minus: momentOffsetSchema.optional(),
224
+ or: z.array(plainMomentSchema).min(1).max(3).optional(),
225
+ })
226
+ .strict()
227
+ .refine((m) => m.plus === undefined || m.minus === undefined, { message: 'a moment is shifted forward (plus) or back (minus), not both' });
228
+ /**
229
+ * A change allowed only after one moment, before another, or between the two
230
+ * — and, keyed by a link, only while the linked row meets `where` (refunds
231
+ * switched on for the event).
232
+ */
233
+ const momentWindowSchema = z
234
+ .object({ after: windowMomentSchema.optional(), before: windowMomentSchema.optional(), where: z.array(stateConditionSchema).min(1).max(8).optional() })
235
+ .strict()
236
+ .refine((w) => w.after !== undefined || w.before !== undefined || w.where !== undefined, { message: 'a window says after, before or where' });
237
+ /** The moment one end of a window stands for, keyed by `key` of the entry's table. */
238
+ function windowMoment(key, ref, linked) {
239
+ const { column, ...rest } = ref;
240
+ return linked ? { ...rest, column: column ?? '', via: key } : { ...rest, column: key };
241
+ }
242
+ /**
243
+ * Which rows of a codes table count when a typed code is looked up: a value a
244
+ * column must hold (`active = true`), or a date it must not be past
245
+ * (`notBefore: 'now'` on a `valid_until`, `orEmpty` for none) or before.
246
+ */
247
+ export const codeWhereSchema = z
248
+ .array(z.union([
249
+ z.object({ column: refSchema, eq: scalarSchema }).strict(),
250
+ z.object({ column: refSchema, notBefore: z.enum(['now', 'today']), orEmpty: z.literal(true).optional() }).strict(),
251
+ z.object({ column: refSchema, notAfter: z.enum(['now', 'today']), orEmpty: z.literal(true).optional() }).strict(),
252
+ ]))
253
+ .max(4);
254
+ /*
255
+ * ── A write with its child rows ──────────────────────────────────────────
256
+ * A create may carry the rows that belong to it (an order's lines, each
257
+ * line's options), two levels at most, in one write. `agrees` and `counts`
258
+ * say which choices fit together; they are checked by Adminium, never trusted
259
+ * from the browser.
260
+ */
261
+ /** A path of foreign keys, followed one after another; the last names the column compared. */
262
+ const pathSchema = z.array(refSchema).min(1).max(3);
263
+ /**
264
+ * What an `agrees` rule compares a row's value with: a column of the row it
265
+ * belongs to (`parent`, followed along `path`), a column of a row this row
266
+ * points at (`via`), or a fixed value.
267
+ */
268
+ const agreeTargetSchema = z.union([
269
+ z.object({ parent: refSchema, path: pathSchema.optional() }).strict(),
270
+ z.object({ via: refSchema, column: refSchema }).strict(),
271
+ z.object({ value: scalarSchema }).strict(),
272
+ ]);
273
+ /**
274
+ * A value of the row that must agree with another: `column` (followed along
275
+ * `path` through foreign keys) equals, is at most, or is at least the target.
276
+ * `when` applies it only to rows whose value (along its own `path`) is one of
277
+ * `in`.
278
+ */
279
+ export const agreeSchema = z
280
+ .object({
281
+ column: refSchema,
282
+ path: pathSchema.optional(),
283
+ when: z.object({ path: pathSchema.optional(), in: z.array(scalarSchema).min(1).max(32) }).strict().optional(),
284
+ eq: agreeTargetSchema.optional(),
285
+ lte: agreeTargetSchema.optional(),
286
+ gte: agreeTargetSchema.optional(),
287
+ })
288
+ .strict();
289
+ /**
290
+ * How many sibling rows may fall in one group: `by` follows this row's
291
+ * foreign keys to the group (an option's group), whose `min` and `max`
292
+ * columns bound the count. `every` names the groups judged even when no row
293
+ * falls in them (a required size left out): those whose `column` equals the
294
+ * parent row's.
295
+ */
296
+ export const countsSchema = z
297
+ .object({
298
+ by: pathSchema,
299
+ every: z.object({ column: refSchema, eq: z.object({ parent: refSchema }).strict() }).strict().optional(),
300
+ min: refSchema,
301
+ max: refSchema,
302
+ })
303
+ .strict();
304
+ /** The largest a child column may add up to across one write: a number, or a column of the settings row. */
305
+ const sumMaxSchema = z
306
+ .object({ column: refSchema, max: z.union([z.number().int().min(1), settingRefSchema]) })
307
+ .strict();
308
+ /** The fields every level of child rows declares. */
309
+ const childEntryShape = {
310
+ /** The child's foreign key to the row it belongs to. */
311
+ via: refSchema,
312
+ writable: z.array(refSchema),
313
+ /** What the reply shows of each child row; without it, the child's key only. */
314
+ select: z.array(refSchema).optional(),
315
+ defaults: z.record(refSchema, z.union([z.string(), z.number(), z.boolean()])).optional(),
316
+ writableValues: z.record(refSchema, valuesSchema).optional(),
317
+ requires: z.array(refSchema).min(1).max(8).optional(),
318
+ /** A whole-number column Adminium numbers 1, 2, 3… in the order the rows were sent. */
319
+ position: refSchema.optional(),
320
+ /** Rows per parent row. */
321
+ min: z.number().int().min(0).optional(),
322
+ max: z.number().int().min(1).max(200),
323
+ agrees: z.array(agreeSchema).min(1).max(8).optional(),
324
+ counts: z.array(countsSchema).min(1).max(2).optional(),
325
+ /** Columns that hold plain text only (no links), as on a create nobody signed in for. */
326
+ plainText: z.array(refSchema).min(1).max(8).optional(),
327
+ /** The most a column may add up to across the rows of one write (a dozen items to an order). */
328
+ sumMax: sumMaxSchema.optional(),
329
+ };
330
+ const childTablesSchema = (entry) => z.record(refSchema, entry).refine((children) => {
331
+ const count = Object.keys(children).length;
332
+ return count >= 1 && count <= 4;
333
+ }, { message: 'one to four child tables at each level' });
334
+ /** A child row one level down (an option of a line): no children of its own. */
335
+ const grandchildEntrySchema = z.object(childEntryShape).strict();
336
+ /** A child row of a create (an order's line), with at most one more level below it. */
337
+ export const childEntrySchema = z.object({ ...childEntryShape, children: childTablesSchema(grandchildEntrySchema).optional() }).strict();
338
+ /**
339
+ * The person a create is made for, found by the address typed into `email`
340
+ * or made when none has it (`fill` sets the new row's columns from the
341
+ * entry's), linked through `link`. The guest is never told which happened.
342
+ */
343
+ const findOrCreateSchema = z
344
+ .object({
345
+ table: refSchema,
346
+ email: refSchema,
347
+ link: refSchema,
348
+ fill: z
349
+ .record(refSchema, refSchema)
350
+ .refine((fill) => Object.keys(fill).length <= 4, { message: 'a new person is filled from at most four columns' })
351
+ .optional(),
352
+ /**
353
+ * On a change through the row's own link: find the person only on the
354
+ * save that moves the row to this state (a ticket accepted), not on every
355
+ * save made while the row has no person.
356
+ */
357
+ on: z.object({ to: z.string().min(1).max(64) }).strict().optional(),
358
+ })
359
+ .strict();
360
+ /**
361
+ * Columns left out of a row: once it has a holder who is not the reader
362
+ * (`unlessHolder`, on rows read through a parent), and — `when` — while the
363
+ * row, or a row one of its links points at, holds these values (a ticket of
364
+ * an order not paid yet; a ticket a friend has not taken yet, read by its own
365
+ * link). A `when` holds for the readers of the key the entry is served
366
+ * through; a holder is every reader's.
367
+ */
368
+ const withholdSchema = z
369
+ .object({
370
+ columns: z.array(refSchema).min(1).max(8),
371
+ unlessHolder: refSchema.optional(),
372
+ when: z
373
+ .object({ where: z.array(stateConditionSchema).min(1).max(8).optional(), linked: z.array(linkedConditionSchema).min(1).max(4).optional() })
374
+ .strict()
375
+ .refine((when) => when.where !== undefined || when.linked !== undefined, { message: 'a when names where, linked, or both' })
376
+ .optional(),
377
+ })
378
+ .strict()
379
+ .refine((w) => w.unlessHolder !== undefined || w.when !== undefined, { message: 'a withhold names its holder, a when, or both' });
380
+ /**
381
+ * What "delete my details" empties on a person's own row, and the time it
382
+ * stamps. `links`: it also stops the person's rows' own links (each gets a
383
+ * new code, which closes every session its old one opened) — an app's choice.
384
+ */
385
+ const forgetSchema = z.object({ columns: z.array(refSchema).min(1).max(16), stamp: refSchema.optional(), links: z.literal(true).optional() }).strict();
386
+ /**
387
+ * "Make a new link" for a signed-in person's row: its own link (`column`)
388
+ * gets a new code, which closes every session the old one opened, and the new
389
+ * link is emailed as the app's outbox message `kind` (sent once per code).
390
+ * On a row's own link it is "send it again": another link of the row (the
391
+ * one a confirmation email carries) is made again and mailed to the row's own
392
+ * address, only while the row holds `when`.
393
+ */
394
+ const newLinkSchema = z
395
+ .object({ column: refSchema, kind: z.string().min(1).max(40), when: z.object({ where: z.array(stateConditionSchema).min(1).max(8) }).strict().optional() })
396
+ .strict();
106
397
  export const publicAccessSchema = z
107
398
  .object({
108
399
  table: refSchema,
@@ -123,6 +414,14 @@ export const publicAccessSchema = z
123
414
  claimedBy: z.object({ table: refSchema, column: refSchema, optional: z.literal(true).optional() }).strict().optional(),
124
415
  /** The session this entry needs: `verified` once the emailed code is confirmed. */
125
416
  level: z.enum(['lookup', 'verified']).optional(),
417
+ /** A create for a person found (or made) by the address typed (see `findOrCreateSchema`). */
418
+ identity: findOrCreateSchema.optional(),
419
+ /** A create answers, once, the new row's own link: this share-code column's code. */
420
+ shareLink: refSchema.optional(),
421
+ /** On an identity entry: what "delete my details" empties. */
422
+ forget: forgetSchema.optional(),
423
+ /** On a signed-in person's rows: "Make a new link" for a row's own link. */
424
+ newLink: newLinkSchema.optional(),
126
425
  /**
127
426
  * Rows readable only where a parent entry (the one on `table`, on the same
128
427
  * key) reads the row they belong to. `via` is this table's foreign key to
@@ -162,10 +461,20 @@ export const publicAccessSchema = z
162
461
  * past it may ask for a new price).
163
462
  */
164
463
  writableWhen: z
165
- .record(refSchema, z.union([whenValuesSchema, z.literal('from-now'), z.literal('from-today'), z.literal('before-today'), timeWindowSchema]))
464
+ .record(refSchema, z.union([whenValuesSchema, z.literal('from-now'), z.literal('from-today'), z.literal('before-today'), timeWindowSchema, momentWindowSchema]))
166
465
  .optional(),
167
466
  /** A proof-of-work the browser solves before the write (or the claim) is taken. */
168
467
  humanCheck: z.literal(true).optional(),
468
+ /** The rows a create carries with it, by child table (see `childEntrySchema`). */
469
+ children: childTablesSchema(childEntrySchema).optional(),
470
+ /** Checks the created row's own values must pass (guests no more than a room sleeps). */
471
+ agrees: z.array(agreeSchema).min(1).max(8).optional(),
472
+ /** The same write may be asked for without writing, to see every figure Adminium would work out. */
473
+ dryRun: z.literal(true).optional(),
474
+ /** A money column the write may send its expected value for; a different figure writes nothing. */
475
+ expect: refSchema.optional(),
476
+ /** A column holding a key the browser mints, so a retried write lands on the same row. */
477
+ clientKey: refSchema.optional(),
169
478
  /** A create answers where the new row stands: the rows ordered at or before it. */
170
479
  rank: z
171
480
  .object({ orderBy: refSchema, where: z.object({ column: refSchema, eq: scalarSchema }).strict().optional() })
@@ -184,9 +493,29 @@ export const publicAccessSchema = z
184
493
  .object({
185
494
  perValue: z.object({ columns: z.array(refSchema).min(1).max(4), n: z.number().int().min(1).max(20) }).strict().optional(),
186
495
  perKeyHour: z.number().int().min(1).max(1000).optional(),
496
+ /**
497
+ * At most this many an hour from one visitor (an IPv6 subscriber's
498
+ * whole /64) through this entry: never more than the 60 every
499
+ * visitor is held to on any key, only fewer.
500
+ */
501
+ perIpHour: z.number().int().min(1).max(60).optional(),
502
+ plainText: z.array(refSchema).min(1).max(8).optional(),
503
+ })
504
+ .strict()
505
+ .optional(),
506
+ /**
507
+ * The limits on a change a guest makes, signed in or not: per value a day
508
+ * (each change that writes one of the columns counts against the value it
509
+ * writes — an address a ticket is sent on to), and columns that hold
510
+ * plain text only (the name it is sent to).
511
+ */
512
+ limits: z
513
+ .object({
514
+ perValue: z.object({ columns: z.array(refSchema).min(1).max(4), n: z.number().int().min(1).max(20) }).strict().optional(),
187
515
  plainText: z.array(refSchema).min(1).max(8).optional(),
188
516
  })
189
517
  .strict()
518
+ .refine((l) => l.perValue !== undefined || l.plainText !== undefined, { message: 'limits name a per-value cap, plain-text columns, or both' })
190
519
  .optional(),
191
520
  /** Refused while a bool in the settings row is false (only for a session-less create with `anonymous`). */
192
521
  requireSetting: z
@@ -216,6 +545,34 @@ export const publicAccessSchema = z
216
545
  })
217
546
  .strict()
218
547
  .optional(),
548
+ /** Availability: which of the table's limits it answers (absent: the first). */
549
+ rule: z.number().int().min(0).max(2).optional(),
550
+ /** Availability: say what is left, but only when little is (below a number, or a share of the pool). */
551
+ showLeft: z
552
+ .union([
553
+ z.object({ below: z.number().int().min(1) }).strict(),
554
+ z.object({ belowShare: z.number().int().min(1).max(100) }).strict(),
555
+ ])
556
+ .optional(),
557
+ /** Availability of a parent limit: the column of the pool's rows a page asks by (an event's ticket types). */
558
+ under: refSchema.optional(),
559
+ /**
560
+ * Rows readable only with a code that unlocks them: a row of `table`
561
+ * whose `column` holds the typed code, and whose `link` points at the row
562
+ * (a presale code revealing its ticket type).
563
+ */
564
+ unlockBy: z
565
+ .object({ table: refSchema, column: refSchema, link: refSchema, where: codeWhereSchema.optional() })
566
+ .strict()
567
+ .optional(),
568
+ /** Image columns any visitor may see, through the rows this entry reads. */
569
+ pictures: z.array(refSchema).min(1).max(4).optional(),
570
+ /**
571
+ * On rows reached through a parent: `columns` are left out unless
572
+ * `unlessHolder` is empty or names the session's own person (a ticket
573
+ * sent to a friend keeps its code from the buyer who sent it).
574
+ */
575
+ withhold: withholdSchema.optional(),
219
576
  })
220
577
  .strict();
221
578
  /**
@@ -238,6 +595,71 @@ export const publicKeySchema = z
238
595
  export const publicKeysSchema = z
239
596
  .record(keyNameSchema, publicKeySchema)
240
597
  .refine((keys) => !(CUSTOMER_KEY in keys), { message: '"customer" is the app\'s own key and is not declared here' });
598
+ /**
599
+ * The own links of the rows a person of `identityTable` holds: each table a
600
+ * token claims with `own: true`, by its code column, and the columns of it
601
+ * that point at the person — the ones a signed-in read claims by, or a found
602
+ * person is linked through.
603
+ */
604
+ export function ownLinksOfPerson(entries, identityTable) {
605
+ const out = [];
606
+ for (const entry of entries) {
607
+ const claim = entry.claim;
608
+ if (claim === undefined || !('by' in claim) || claim.own !== true)
609
+ continue;
610
+ if (out.some((link) => link.table === entry.table && link.column === claim.column))
611
+ continue;
612
+ const people = new Set();
613
+ for (const other of entries) {
614
+ if (other.table !== entry.table)
615
+ continue;
616
+ if (other.claimedBy !== undefined && other.claimedBy.table === identityTable)
617
+ people.add(other.claimedBy.column);
618
+ if (other.identity !== undefined && other.identity.table === identityTable)
619
+ people.add(other.identity.link);
620
+ }
621
+ if (people.size > 0)
622
+ out.push({ table: entry.table, column: claim.column, people: [...people].sort() });
623
+ }
624
+ return out;
625
+ }
626
+ /**
627
+ * How a message of `kind` about a row of `table` is addressed when it is
628
+ * sent again through the row's own link — as the kind's own producer
629
+ * declares it (the first producer of the kind, as the sender reads it), and
630
+ * only from the row: a column of the row, the person it links (the column
631
+ * named as the outbox's recipient link, else its one key to the recipient's
632
+ * table), or the outbox's fallback through this table. Null when nothing on
633
+ * the row addresses it (a setting's address, a column of another row, two
634
+ * keys to the recipient). The validator and the server ask this one function.
635
+ */
636
+ export function resendAddressOf(outbox, kind, table, columnsOf) {
637
+ const find = (at, name) => columnsOf(at)?.find((c) => c.name === name);
638
+ const declared = (outbox.producers ?? []).find((producer) => producer.kind === kind);
639
+ const own = declared?.recipient;
640
+ if (own !== undefined && 'setting' in own)
641
+ return null;
642
+ if (own !== undefined) {
643
+ // The producing row is the one the producer links: it must be this row.
644
+ if (find(outbox.table, declared.link)?.references !== table || find(table, own.column) === undefined)
645
+ return null;
646
+ return { recipient: { ...own }, columns: [own.column] };
647
+ }
648
+ const columns = [];
649
+ let person;
650
+ const keys = (columnsOf(table) ?? []).filter((c) => c.references === outbox.recipient.table);
651
+ const link = find(table, outbox.recipient.via) !== undefined ? outbox.recipient.via : keys.length === 1 ? keys[0].name : undefined;
652
+ if (link !== undefined) {
653
+ columns.push(link);
654
+ person = { table: outbox.recipient.table, column: outbox.recipient.email };
655
+ }
656
+ const fallback = outbox.recipient.fallback;
657
+ if (fallback !== undefined && find(outbox.table, fallback.via)?.references === table && find(table, fallback.email) !== undefined)
658
+ columns.push(fallback.email);
659
+ if (columns.length === 0)
660
+ return null;
661
+ return { columns, ...(person === undefined ? {} : { person }) };
662
+ }
241
663
  /** Everything in `publicAccess` and `publicKeys` that names something undeclared, or breaks a rule. */
242
664
  export function publicAccessIssues(entries, ctx) {
243
665
  const out = [];
@@ -251,8 +673,10 @@ export function publicAccessIssues(entries, ctx) {
251
673
  if (identity?.claim === undefined || claimKind(identity.claim) !== 'token') {
252
674
  out.push({ path: [...at, 'requiresStaff'], message: `a key no staff signs in opens one row by its token: "${name}" needs an entry that claims by token` });
253
675
  }
676
+ // The owner's own link may change its row (what it may change is checked below); any other only reads.
677
+ const ownLink = identity?.claim !== undefined && 'by' in identity.claim && identity.claim.own === true;
254
678
  entries.forEach((entry, i) => {
255
- if ((entry.key ?? CUSTOMER_KEY) === name && entry.methods.some((method) => method !== 'GET')) {
679
+ if (!ownLink && (entry.key ?? CUSTOMER_KEY) === name && entry.methods.some((method) => method !== 'GET')) {
256
680
  out.push({ path: ['publicAccess', i, 'methods'], message: `"${name}" opens a row to whoever holds its link, so it only reads` });
257
681
  }
258
682
  });
@@ -345,6 +769,26 @@ export function publicAccessIssues(entries, ctx) {
345
769
  out.push({ path: at(list), message: `"${entry.table}" has no column "${ref}"` });
346
770
  }
347
771
  }
772
+ // An entry anyone may call (no claim, no parent, no session asked) shows no personal data: the install refuses it too.
773
+ const sessionOnly = entry.level !== undefined && entry.claim === undefined && entry.claimedBy === undefined && entry.visibleWith === undefined;
774
+ const anyone = entry.claim === undefined && entry.visibleWith === undefined && (entry.claimedBy === undefined || entry.claimedBy.optional === true) && !sessionOnly;
775
+ // An availability entry shows no row, whatever it names.
776
+ if (anyone && entry.kind !== 'availability') {
777
+ // No select shows every column but a code Adminium makes and a secret, as the install reads it.
778
+ const shown = entry.select ??
779
+ table.columns.filter((c) => c.rules?.['code'] === undefined && c.rules?.['secret'] !== true).map((c) => c.ref);
780
+ for (const ref of shown) {
781
+ const personal = personalColumn(table, ref);
782
+ if (personal !== null) {
783
+ out.push({
784
+ path: at('select'),
785
+ message: personal === 'marked'
786
+ ? `"${entry.table}.${ref}" is personal data, and anyone may call this entry, so it is not selected`
787
+ : `"${entry.table}.${ref}" is read as personal data by its name, and anyone may call this entry, so it is not selected (a column that is not personal says \`personal: false\`)`,
788
+ });
789
+ }
790
+ }
791
+ }
348
792
  for (const ref of [
349
793
  ...(entry.claim !== undefined && 'match' in entry.claim ? entry.claim.match : []),
350
794
  ...(entry.filters ?? []).map((f) => f.column),
@@ -467,7 +911,8 @@ export function publicAccessIssues(entries, ctx) {
467
911
  }
468
912
  else {
469
913
  for (const name of ['level', 'onClaim', 'maxOpen']) {
470
- if (name === 'level' && entry.visibleWith !== undefined)
914
+ // A level on an entry with no claim of its own: a read for a session's holder alone (checked below).
915
+ if (name === 'level' && entry.claim === undefined)
471
916
  continue;
472
917
  if (entry[name] !== undefined)
473
918
  out.push({ path: at(name), message: `${name} applies to an entry with claimedBy` });
@@ -482,7 +927,7 @@ export function publicAccessIssues(entries, ctx) {
482
927
  const v = entry.visibleWith;
483
928
  if (entry.claimedBy !== undefined)
484
929
  out.push({ path: at('visibleWith'), message: 'an entry is claimed or visible with a parent, not both' });
485
- const parents = entries.filter((other) => other !== entry && other.table === v.table && (other.key ?? CUSTOMER_KEY) === key && other.methods.includes('GET'));
930
+ const parents = entries.filter((other) => other !== entry && other.table === v.table && (other.key ?? CUSTOMER_KEY) === key && readsRows(other));
486
931
  if (parents.length === 0) {
487
932
  out.push({ path: at('visibleWith', 'table'), message: `no entry reads "${v.table}" on the "${key}" key` });
488
933
  }
@@ -517,6 +962,26 @@ export function publicAccessIssues(entries, ctx) {
517
962
  if (entry.methods.includes('PATCH') && entry.writable === undefined) {
518
963
  out.push({ path: at('writable'), message: 'a change through an entry visible with a parent names what it may write' });
519
964
  }
965
+ // A child's create names the parent it is made under by its link, which the create proves the session reaches.
966
+ if (creates && pointsUp && !writable.has(v.via)) {
967
+ out.push({ path: at('writable'), message: `this entry creates rows under "${v.table}", so "${v.via}" is writable: the parent a create names` });
968
+ }
969
+ /*
970
+ * A column of the child pointing at the key's own person (a note's
971
+ * `client_id`) is a copy the desk keeps, filled from the parent, never
972
+ * written by a browser — unless it is the link a create-only entry names
973
+ * its parent by (an extra added to the stay the stay's own link opened).
974
+ */
975
+ const person = entries.find((other) => other.claim !== undefined && (other.key ?? CUSTOMER_KEY) === key)?.table;
976
+ if (person !== undefined) {
977
+ for (const ref of entry.writable ?? []) {
978
+ const found = column(ref);
979
+ const namesParent = ref === v.via && creates && !patches;
980
+ if (found?.type === 'fk' && found.references === person && !namesParent) {
981
+ out.push({ path: at('writable'), message: `"${entry.table}.${ref}" points at the signed-in person's own table, so it is filled from the parent and never written publicly` });
982
+ }
983
+ }
984
+ }
520
985
  if (hops(entries, i) > 2)
521
986
  out.push({ path: at('visibleWith'), message: 'an entry is at most two steps from the entry its person claims' });
522
987
  if (root === undefined)
@@ -540,7 +1005,12 @@ export function publicAccessIssues(entries, ctx) {
540
1005
  if (entry.humanCheck === true && entry.claimedBy !== undefined && identity !== undefined && identity.entry.humanCheck !== true) {
541
1006
  out.push({ path: at('humanCheck'), message: `the "${key}" key's identity asks no proof, so this entry's proof could be skipped by claiming first` });
542
1007
  }
543
- if (entry.level === 'verified' && identity !== undefined && identity.entry.claim !== undefined && claimKind(identity.entry.claim) !== 'link' && !('verify' in identity.entry.claim && identity.entry.claim.verify !== undefined)) {
1008
+ if (entry.level === 'verified' &&
1009
+ identity !== undefined &&
1010
+ identity.entry.claim !== undefined &&
1011
+ claimKind(identity.entry.claim) !== 'link' &&
1012
+ !('verify' in identity.entry.claim && identity.entry.claim.verify !== undefined) &&
1013
+ !('own' in identity.entry.claim && identity.entry.claim.own === true)) {
544
1014
  out.push({ path: at('level'), message: `the "${key}" key's identity sends no code, so no session is ever verified` });
545
1015
  }
546
1016
  if (entry.sensitive === true && entry.claimedBy !== undefined && entry.level !== 'verified') {
@@ -564,6 +1034,7 @@ export function publicAccessIssues(entries, ctx) {
564
1034
  if (!writable.has(ref))
565
1035
  out.push({ path: at('requires'), message: `"${ref}" is not writable, so a write cannot fill it` });
566
1036
  }
1037
+ const states = table.states;
567
1038
  for (const [ref, values] of Object.entries(entry.writableValues ?? {})) {
568
1039
  if (!writable.has(ref))
569
1040
  out.push({ path: at('writableValues', ref), message: `"${ref}" is not writable` });
@@ -572,10 +1043,28 @@ export function publicAccessIssues(entries, ctx) {
572
1043
  for (const value of values) {
573
1044
  if (!valueFits(found, value))
574
1045
  out.push({ path: at('writableValues', ref), message: `${JSON.stringify(value)} is not a value of "${entry.table}.${ref}"` });
1046
+ // A guest's write names no state it saw: a state only an undo reaches is never theirs to write.
1047
+ else if (states?.column === ref && reachedOnlyByUndo(states, value))
1048
+ out.push({ path: at('writableValues', ref), message: `every move to ${JSON.stringify(value)} is an undo, which only a person makes` });
575
1049
  }
576
1050
  }
577
- if (entry.writableWhen !== undefined && !patches) {
578
- out.push({ path: at('writableWhen'), message: 'writableWhen limits a change, and this entry changes nothing' });
1051
+ /*
1052
+ * A child's create inside a window read from its parent row (an extra
1053
+ * added until the arrival day's check-in time): keyed by the link to the
1054
+ * parent, every end a time of the parent's. The link names the parent a
1055
+ * create is for, so the create writes it; the window is the parent's.
1056
+ */
1057
+ const createWindow = creates &&
1058
+ !patches &&
1059
+ entry.visibleWith !== undefined &&
1060
+ Object.entries(entry.writableWhen ?? {}).every(([ref, when]) => ref === entry.visibleWith.via &&
1061
+ typeof when === 'object' &&
1062
+ !Array.isArray(when) &&
1063
+ !('within' in when) &&
1064
+ [when.after, when.before].some((end) => end !== undefined) &&
1065
+ [when.after, when.before].every((end) => end === undefined || end.column !== undefined));
1066
+ if (entry.writableWhen !== undefined && !patches && !createWindow) {
1067
+ out.push({ path: at('writableWhen'), message: "writableWhen limits a change, and this entry changes nothing (a child's create takes a window read from its parent, keyed by the link)" });
579
1068
  }
580
1069
  for (const [ref, when] of Object.entries(entry.writableWhen ?? {})) {
581
1070
  const found = column(ref);
@@ -590,6 +1079,26 @@ export function publicAccessIssues(entries, ctx) {
590
1079
  if (found.type !== 'date')
591
1080
  out.push({ path: at('writableWhen', ref), message: `"${when}" needs a date, and "${ref}" is not one` });
592
1081
  }
1082
+ else if (!Array.isArray(when) && !('within' in when)) {
1083
+ out.push(...windowIssues(entry.table, ref, when, found, index, at('writableWhen', ref)));
1084
+ // The dates that open a guest's own window are never theirs to move.
1085
+ // A time of day read from the row itself opens it too: a later arrival time typed in would reopen a closed window.
1086
+ const rowTime = (time) => typeof time === 'object' && time !== null && !('table' in time) && !('edge' in time) && typeof time.column === 'string' ? [time.column] : [];
1087
+ const opening = [
1088
+ ref,
1089
+ ...[when.after, when.before].flatMap((end) => [
1090
+ ...(end !== undefined && end.column === undefined ? rowTime(end.time) : []),
1091
+ ...(end?.or ?? []).filter((m) => m.via === undefined).flatMap((m) => [m.column, ...rowTime(m.time)]),
1092
+ ]),
1093
+ ];
1094
+ for (const column of new Set(opening)) {
1095
+ if (createWindow && column === ref)
1096
+ continue;
1097
+ if (writable.has(column) || entry.writableValues?.[column] !== undefined || entry.defaults?.[column] !== undefined) {
1098
+ out.push({ path: at('writableWhen', ref), message: `"${column}" decides when this row may change, so a write through this entry may not set it` });
1099
+ }
1100
+ }
1101
+ }
593
1102
  else if (!Array.isArray(when)) {
594
1103
  if (found.type !== 'timestamptz')
595
1104
  out.push({ path: at('writableWhen', ref), message: `"within" needs a timestamptz, and "${ref}" is not one` });
@@ -607,7 +1116,7 @@ export function publicAccessIssues(entries, ctx) {
607
1116
  }
608
1117
  }
609
1118
  // The early refusal names one time, so an entry keeps one window.
610
- const windows = Object.values(entry.writableWhen ?? {}).filter((when) => typeof when === 'object' && !Array.isArray(when));
1119
+ const windows = Object.values(entry.writableWhen ?? {}).filter((when) => typeof when === 'object' && !Array.isArray(when) && ('within' in when || when.after !== undefined || when.before !== undefined));
611
1120
  if (windows.length > 1) {
612
1121
  out.push({ path: at('writableWhen'), message: 'one time window per entry: a change refused as too early names one time' });
613
1122
  }
@@ -662,6 +1171,22 @@ export function publicAccessIssues(entries, ctx) {
662
1171
  }
663
1172
  }
664
1173
  }
1174
+ if (entry.limits !== undefined) {
1175
+ const limits = entry.limits;
1176
+ if (!patches)
1177
+ out.push({ path: at('limits'), message: 'limits apply to a change (PATCH); a create has anonymous' });
1178
+ for (const [name, columns] of [['perValue', limits.perValue?.columns ?? []], ['plainText', limits.plainText ?? []]]) {
1179
+ for (const ref of columns) {
1180
+ const found = column(ref);
1181
+ if (found === undefined)
1182
+ out.push({ path: at('limits', name), message: `"${entry.table}" has no column "${ref}"` });
1183
+ else if (found.type !== 'text')
1184
+ out.push({ path: at('limits', name), message: `"${entry.table}.${ref}" is not a text column` });
1185
+ else if (!(entry.writable ?? []).includes(ref))
1186
+ out.push({ path: at('limits', name), message: `"${ref}" is not writable, so a guest never sends it` });
1187
+ }
1188
+ }
1189
+ }
665
1190
  for (const [r, setting] of (entry.requireSetting ?? []).entries()) {
666
1191
  if (index.column(setting.table, setting.column)?.type !== 'bool') {
667
1192
  out.push({ path: at('requireSetting', r), message: `"${setting.table}.${setting.column}" is not a bool of this app` });
@@ -697,9 +1222,163 @@ export function publicAccessIssues(entries, ctx) {
697
1222
  if (entry.methods.some((method) => method !== 'GET'))
698
1223
  out.push({ path: at('methods'), message: 'availability is read-only' });
699
1224
  }
1225
+ out.push(...availabilityShapeIssues(entry, ctx, at));
1226
+ if (entry.unlockBy !== undefined)
1227
+ out.push(...unlockIssues(entry, entry.unlockBy, entries, ctx, at));
1228
+ if (entry.pictures !== undefined)
1229
+ out.push(...pictureIssues(entry, entry.pictures, entries, ctx, at));
1230
+ });
1231
+ out.push(...treeIssues(entries, ctx, identities, shareCodes));
1232
+ out.push(...personIssues(entries, ctx, identities));
1233
+ out.push(...ownAddressAndWithholdIssues(entries, ctx, identities));
1234
+ return out;
1235
+ }
1236
+ /** `rule`, `showLeft` and `under`: which limit an availability entry answers, and how. */
1237
+ function availabilityShapeIssues(entry, ctx, at) {
1238
+ const out = [];
1239
+ const named = ['rule', 'showLeft', 'under'].filter((name) => entry[name] !== undefined);
1240
+ if (named.length === 0)
1241
+ return out;
1242
+ if (entry.kind !== 'availability') {
1243
+ for (const name of named)
1244
+ out.push({ path: at(name), message: `${name} shapes an availability answer, and this entry reads rows` });
1245
+ return out;
1246
+ }
1247
+ if (ctx.capacityOf === undefined)
1248
+ return out;
1249
+ const rules = rulesOf(ctx.capacityOf(entry.table));
1250
+ if (rules.length === 0) {
1251
+ for (const name of named)
1252
+ out.push({ path: at(name), message: `${name} answers a limit, and "${entry.table}" has none` });
1253
+ return out;
1254
+ }
1255
+ const index = entry.rule ?? 0;
1256
+ const rule = rules[index];
1257
+ if (rule === undefined) {
1258
+ out.push({ path: at('rule'), message: `"${entry.table}" has ${String(rules.length)} limit${rules.length === 1 ? '' : 's'}, so rule is 0 to ${String(rules.length - 1)}` });
1259
+ return out;
1260
+ }
1261
+ const kind = kindOf(rule);
1262
+ if (entry.showLeft !== undefined && kind === 'slot') {
1263
+ out.push({ path: at('showLeft'), message: 'what is left is shown of a pool: a parent or night limit' });
1264
+ }
1265
+ if (entry.under !== undefined) {
1266
+ if (rule.kind !== 'parent') {
1267
+ out.push({ path: at('under'), message: 'under asks a parent limit by a column of its pools' });
1268
+ }
1269
+ else {
1270
+ const target = ctx.index.column(entry.table, rule.via)?.references;
1271
+ if (target !== undefined && !ctx.index.has(target, entry.under))
1272
+ out.push({ path: at('under'), message: `"${target}" has no column "${entry.under}"` });
1273
+ }
1274
+ }
1275
+ if (rule.kind === 'night' && 'size' in rule.pool && rule.pool.size === 1) {
1276
+ out.push({ path: at('rule'), message: 'availability answers a pool, not one row' });
1277
+ }
1278
+ return out;
1279
+ }
1280
+ /** An entry that shows rows only with the code that unlocks them. */
1281
+ function unlockIssues(entry, unlock, entries, ctx, at) {
1282
+ const out = [];
1283
+ const { index } = ctx;
1284
+ if (entry.methods.length !== 1 || entry.methods[0] !== 'GET')
1285
+ out.push({ path: at('methods'), message: 'an unlock only reads' });
1286
+ for (const name of ['claim', 'claimedBy', 'visibleWith']) {
1287
+ if (entry[name] !== undefined)
1288
+ out.push({ path: at(name), message: 'an unlock is its own entry' });
1289
+ }
1290
+ if (entry.kind === 'availability')
1291
+ out.push({ path: at('kind'), message: 'an unlock is its own entry' });
1292
+ if (index.table(unlock.table) === undefined) {
1293
+ out.push({ path: at('unlockBy', 'table'), message: `"${unlock.table}" is not a table of this app` });
1294
+ return out;
1295
+ }
1296
+ const link = index.column(unlock.table, unlock.link);
1297
+ if (link === undefined)
1298
+ out.push({ path: at('unlockBy', 'link'), message: `"${unlock.table}" has no column "${unlock.link}"` });
1299
+ else if (link.type !== 'fk' || link.references !== entry.table) {
1300
+ out.push({ path: at('unlockBy', 'link'), message: `"${unlock.table}.${unlock.link}" does not point at "${entry.table}"` });
1301
+ }
1302
+ const code = index.column(unlock.table, unlock.column);
1303
+ if (code === undefined) {
1304
+ out.push({ path: at('unlockBy', 'column'), message: `"${unlock.table}" has no column "${unlock.column}"` });
1305
+ }
1306
+ else {
1307
+ if (code.type !== 'text' || (code.unique !== true && code.rules?.code === undefined)) {
1308
+ out.push({ path: at('unlockBy', 'column'), message: `a code finds one row: make "${unlock.table}.${code.ref}" unique (or unique with its scope)` });
1309
+ }
1310
+ if (code.rules?.code === undefined && code.rules?.normalize !== 'code') {
1311
+ out.push({ path: at('unlockBy', 'column'), message: `"${unlock.table}.${code.ref}" is compared as a code: give it normalize "code"` });
1312
+ }
1313
+ if (shareCodeColumns(entries, unlock.table).includes(code.ref)) {
1314
+ out.push({ path: at('unlockBy', 'column'), message: "a shared link's code is never looked up" });
1315
+ }
1316
+ }
1317
+ (unlock.where ?? []).forEach((condition, k) => {
1318
+ const path = at('unlockBy', 'where', k);
1319
+ const filter = index.column(unlock.table, condition.column);
1320
+ if (filter === undefined)
1321
+ out.push({ path: [...path, 'column'], message: `"${unlock.table}" has no column "${condition.column}"` });
1322
+ else if ('eq' in condition) {
1323
+ if (!valueFits(filter, condition.eq))
1324
+ out.push({ path: [...path, 'eq'], message: `${JSON.stringify(condition.eq)} is not a value of "${unlock.table}.${filter.ref}"` });
1325
+ }
1326
+ else if (filter.type !== 'date' && filter.type !== 'timestamptz') {
1327
+ out.push({ path: [...path, 'column'], message: `"${unlock.table}.${filter.ref}" is not a date` });
1328
+ }
700
1329
  });
701
1330
  return out;
702
1331
  }
1332
+ /** Pictures every visitor may see: image columns the entry shows, which no browser writes. */
1333
+ function pictureIssues(entry, pictures, entries, ctx, at) {
1334
+ const out = [];
1335
+ if (entry.methods.length !== 1 || entry.methods[0] !== 'GET' || entry.kind === 'availability') {
1336
+ out.push({ path: at('pictures'), message: 'pictures are shown through an entry that only reads rows' });
1337
+ }
1338
+ if (entry.claim !== undefined || entry.claimedBy !== undefined || entry.visibleWith !== undefined) {
1339
+ out.push({ path: at('pictures'), message: "pictures are for every visitor; a signed-in person's own files are `files`" });
1340
+ }
1341
+ // A picture is fetched by an <img>, which carries no session: rows only a code opens are no rows it can show.
1342
+ if (entry.unlockBy !== undefined)
1343
+ out.push({ path: at('pictures'), message: 'pictures are for every visitor; rows a code unlocks are read only by whoever gave the code' });
1344
+ // Nor rows read by a session's holder alone (an entry with a level and no claim of its own).
1345
+ if (entry.level !== undefined && entry.claim === undefined && entry.claimedBy === undefined && entry.visibleWith === undefined) {
1346
+ out.push({ path: at('pictures'), message: "pictures are for every visitor; rows read by a signed-in session alone are no picture an <img> can show" });
1347
+ }
1348
+ if ((entry.key ?? CUSTOMER_KEY) !== CUSTOMER_KEY)
1349
+ out.push({ path: at('key'), message: "pictures are served through the app's customer key" });
1350
+ const writes = new Set([...(entry.writable ?? []), ...Object.keys(entry.defaults ?? {})]);
1351
+ const shareCodes = shareCodeColumns(entries, entry.table);
1352
+ for (const ref of pictures) {
1353
+ const found = ctx.index.column(entry.table, ref);
1354
+ if (found === undefined) {
1355
+ out.push({ path: at('pictures'), message: `"${entry.table}" has no column "${ref}"` });
1356
+ continue;
1357
+ }
1358
+ if (found.type !== 'text' || found.semantic !== 'image') {
1359
+ out.push({ path: at('pictures'), message: `"${entry.table}.${ref}" is not an image column: text with semantic "image"` });
1360
+ }
1361
+ if (writes.has(ref))
1362
+ out.push({ path: at('pictures'), message: `"${ref}" is a picture anyone sees, so a browser never writes it` });
1363
+ if (entry.select !== undefined && !entry.select.includes(ref))
1364
+ out.push({ path: at('pictures'), message: `"${ref}" is not one of the columns the entry shows` });
1365
+ if ((entry.files ?? []).includes(ref))
1366
+ out.push({ path: at('pictures'), message: `"${ref}" is a signed-in person's file, not a picture for everyone` });
1367
+ if (found.rules?.secret === true || found.rules?.code !== undefined || shareCodes.includes(ref)) {
1368
+ out.push({ path: at('pictures'), message: `"${entry.table}.${ref}" is kept from readers, so it is no picture for everyone` });
1369
+ }
1370
+ if (found.rules?.personal === true)
1371
+ out.push({ path: at('pictures'), message: `"${entry.table}.${ref}" is personal data, so it is no picture for everyone` });
1372
+ }
1373
+ return out;
1374
+ }
1375
+ /**
1376
+ * Whether an entry reads a table's rows: a GET that is not an availability
1377
+ * answer (free or full per slot, never a row), so it can be a child's parent.
1378
+ */
1379
+ function readsRows(entry) {
1380
+ return entry.methods.includes('GET') && entry.kind !== 'availability';
1381
+ }
703
1382
  /**
704
1383
  * The identity entry an entry's person is claimed through: its own claim,
705
1384
  * its key's identity for a claimed entry, or — for one visible with a parent —
@@ -714,7 +1393,7 @@ function rootIdentity(entries, index, identities, seen = new Set()) {
714
1393
  return identities.get(key)?.entry;
715
1394
  if (entry.visibleWith === undefined || seen.has(index))
716
1395
  return undefined;
717
- const parent = entries.findIndex((other, i) => i !== index && other.table === entry.visibleWith.table && (other.key ?? CUSTOMER_KEY) === key && other.methods.includes('GET'));
1396
+ const parent = entries.findIndex((other, i) => i !== index && other.table === entry.visibleWith.table && (other.key ?? CUSTOMER_KEY) === key && readsRows(other));
718
1397
  return parent === -1 ? undefined : rootIdentity(entries, parent, identities, new Set([...seen, index]));
719
1398
  }
720
1399
  /** How many `visibleWith` steps an entry is from a claimed entry (a loop counts as too many). */
@@ -725,7 +1404,909 @@ function hops(entries, index, seen = new Set()) {
725
1404
  if (seen.has(index))
726
1405
  return Number.POSITIVE_INFINITY;
727
1406
  const key = entry.key ?? CUSTOMER_KEY;
728
- const parent = entries.findIndex((other, i) => i !== index && other.table === entry.visibleWith.table && (other.key ?? CUSTOMER_KEY) === key && other.methods.includes('GET'));
1407
+ const parent = entries.findIndex((other, i) => i !== index && other.table === entry.visibleWith.table && (other.key ?? CUSTOMER_KEY) === key && readsRows(other));
729
1408
  return parent === -1 ? 1 : 1 + hops(entries, parent, new Set([...seen, index]));
730
1409
  }
1410
+ /**
1411
+ * Everything wrong with a window read from moments: keyed by a date or a time
1412
+ * of the row, its ends name no column and no linked condition; keyed by a
1413
+ * link, each end names the linked row's column.
1414
+ */
1415
+ function windowIssues(table, key, when, found, index, path) {
1416
+ const out = [];
1417
+ const linked = found.type === 'fk';
1418
+ if (!linked && found.type !== 'date' && found.type !== 'timestamptz') {
1419
+ out.push({ path, message: `"${table}.${key}" is neither a date, a time nor a link, so no window is read from it` });
1420
+ return out;
1421
+ }
1422
+ for (const side of ['after', 'before']) {
1423
+ const end = when[side];
1424
+ if (end === undefined)
1425
+ continue;
1426
+ if (linked && end.column === undefined) {
1427
+ out.push({ path: [...path, side, 'column'], message: `"${key}" is a link: name the column of the row it points at` });
1428
+ continue;
1429
+ }
1430
+ if (!linked && end.column !== undefined) {
1431
+ out.push({ path: [...path, side, 'column'], message: `the window is "${table}.${key}" itself, so it names no other column` });
1432
+ continue;
1433
+ }
1434
+ out.push(...momentIssues(table, windowMoment(key, end, linked), index, [...path, side]));
1435
+ }
1436
+ if (when.where !== undefined) {
1437
+ if (!linked || found.references === undefined || index.table(found.references) === undefined) {
1438
+ out.push({ path: [...path, 'where'], message: `conditions on a linked row are keyed by a link of "${table}"` });
1439
+ }
1440
+ else {
1441
+ const target = found.references;
1442
+ when.where.forEach((condition, w) => out.push(...conditionIssues(target, condition, index, [...path, 'where', w])));
1443
+ }
1444
+ }
1445
+ return out;
1446
+ }
1447
+ const ruledTable = (index, ref) => index.table(ref);
1448
+ const ruledColumn = (index, table, ref) => index.column(table, ref);
1449
+ const NUMBERS = ['int', 'bigint', 'decimal', 'money', 'float'];
1450
+ /** Whether a value fits a column an agreement compares: a key (of any type) is a number or a text. */
1451
+ function agreeValueFits(column, value) {
1452
+ if (column.type === 'fk' || column.role === 'pk')
1453
+ return typeof value === 'number' || typeof value === 'string';
1454
+ return valueFits(column, value);
1455
+ }
1456
+ /** Whether two columns can be compared: two keys of one table, two numbers, or two texts. */
1457
+ function comparable(a, b, index) {
1458
+ // A foreign key compares with another to the same table, or with that table's own key.
1459
+ const keyOf = (c) => (c.type === 'fk' ? c.references : undefined);
1460
+ if (a.type === 'fk' || b.type === 'fk') {
1461
+ const [fk, other] = a.type === 'fk' ? [a, b] : [b, a];
1462
+ if (other.type === 'fk')
1463
+ return keyOf(fk) === keyOf(other);
1464
+ return other.role === 'pk' && fk.references !== undefined && index.pk(fk.references)?.ref === other.ref && index.pk(fk.references)?.type === other.type;
1465
+ }
1466
+ const kind = (c) => (NUMBERS.includes(c.type) ? 'number' : c.type === 'enum' || c.type === 'text' ? 'text' : c.type);
1467
+ return kind(a) === kind(b);
1468
+ }
1469
+ /**
1470
+ * Follow `path` from `column` of `table`: every step but the last is a
1471
+ * foreign key, the last is the column reached. The column reached, or an
1472
+ * issue.
1473
+ */
1474
+ function followPath(index, table, column, path) {
1475
+ const start = index.column(table, column);
1476
+ if (start === undefined)
1477
+ return `"${table}" has no column "${column}"`;
1478
+ if (path === undefined || path.length === 0)
1479
+ return { column: start, table };
1480
+ let at = start;
1481
+ let atTable = table;
1482
+ for (const step of path) {
1483
+ if (at.type !== 'fk' || at.references === undefined)
1484
+ return `"${atTable}.${at.ref}" is not a foreign key, so the path cannot go on to "${step}"`;
1485
+ atTable = at.references;
1486
+ const next = index.column(atTable, step);
1487
+ if (next === undefined)
1488
+ return `"${atTable}" has no column "${step}"`;
1489
+ at = next;
1490
+ }
1491
+ return { column: at, table: atTable };
1492
+ }
1493
+ /** Everything wrong with one `agrees` rule of a row on `table`, whose parent row (if any) is on `parent`. */
1494
+ function agreeIssues(index, table, parent, agree, at) {
1495
+ const out = [];
1496
+ const reached = followPath(index, table, agree.column, agree.path);
1497
+ if (typeof reached === 'string')
1498
+ out.push({ path: at('path'), message: reached });
1499
+ if (agree.when !== undefined) {
1500
+ const when = followPath(index, table, agree.column, agree.when.path);
1501
+ if (typeof when === 'string')
1502
+ out.push({ path: at('when'), message: when });
1503
+ else
1504
+ for (const value of agree.when.in) {
1505
+ if (!agreeValueFits(when.column, value))
1506
+ out.push({ path: at('when', 'in'), message: `${JSON.stringify(value)} is not a value of "${when.table}.${when.column.ref}"` });
1507
+ }
1508
+ }
1509
+ const tests = ['eq', 'lte', 'gte'].filter((name) => agree[name] !== undefined);
1510
+ if (tests.length !== 1) {
1511
+ out.push({ path: at(), message: 'an agreement says one of eq, lte or gte' });
1512
+ return out;
1513
+ }
1514
+ const test = tests[0];
1515
+ const target = agree[test];
1516
+ let other;
1517
+ if ('parent' in target) {
1518
+ if (parent === undefined) {
1519
+ out.push({ path: at(test, 'parent'), message: 'the row a create makes has no parent row to agree with' });
1520
+ }
1521
+ else {
1522
+ const found = followPath(index, parent, target.parent, target.path);
1523
+ if (typeof found === 'string')
1524
+ out.push({ path: at(test), message: found });
1525
+ else
1526
+ other = found.column;
1527
+ }
1528
+ }
1529
+ else if ('via' in target) {
1530
+ const via = index.column(table, target.via);
1531
+ if (via?.type !== 'fk' || via.references === undefined)
1532
+ out.push({ path: at(test, 'via'), message: `"${target.via}" is not a foreign key of "${table}"` });
1533
+ else {
1534
+ other = index.column(via.references, target.column);
1535
+ if (other === undefined)
1536
+ out.push({ path: at(test, 'column'), message: `"${via.references}" has no column "${target.column}"` });
1537
+ }
1538
+ }
1539
+ else if (typeof reached !== 'string' && !agreeValueFits(reached.column, target.value)) {
1540
+ out.push({ path: at(test, 'value'), message: `${JSON.stringify(target.value)} is not a value of "${reached.table}.${reached.column.ref}"` });
1541
+ }
1542
+ if (typeof reached !== 'string') {
1543
+ if (test !== 'eq' && !NUMBERS.includes(reached.column.type)) {
1544
+ out.push({ path: at(test), message: `"${reached.table}.${reached.column.ref}" is not a number, so it is not at most or at least another` });
1545
+ }
1546
+ if (other !== undefined && !comparable(reached.column, other, index)) {
1547
+ out.push({ path: at(test), message: `"${reached.table}.${reached.column.ref}" (${reached.column.type}) cannot be compared with "${other.ref}" (${other.type})` });
1548
+ }
1549
+ }
1550
+ return out;
1551
+ }
1552
+ /** Everything wrong with one `counts` rule of a row on `table`, whose parent row is on `parent`. */
1553
+ function countsIssues(index, table, parent, counts, at) {
1554
+ const out = [];
1555
+ let group = table;
1556
+ for (const step of counts.by) {
1557
+ const link = index.column(group, step);
1558
+ if (link?.type !== 'fk' || link.references === undefined) {
1559
+ out.push({ path: at('by'), message: `"${group}.${step}" is not a foreign key, so it leads to no group` });
1560
+ return out;
1561
+ }
1562
+ group = link.references;
1563
+ }
1564
+ for (const name of ['min', 'max']) {
1565
+ const found = index.column(group, counts[name]);
1566
+ if (found === undefined)
1567
+ out.push({ path: at(name), message: `"${group}" has no column "${counts[name]}"` });
1568
+ else if (found.type !== 'int' && found.type !== 'bigint')
1569
+ out.push({ path: at(name), message: `"${group}.${counts[name]}" is not a whole number` });
1570
+ }
1571
+ if (counts.every !== undefined) {
1572
+ const column = index.column(group, counts.every.column);
1573
+ if (column?.type !== 'fk')
1574
+ out.push({ path: at('every', 'column'), message: `"${group}.${counts.every.column}" is not a foreign key of the group` });
1575
+ const own = index.column(parent, counts.every.eq.parent);
1576
+ if (own === undefined)
1577
+ out.push({ path: at('every', 'eq', 'parent'), message: `"${parent}" has no column "${counts.every.eq.parent}"` });
1578
+ else if (column !== undefined && !comparable(column, own, index)) {
1579
+ out.push({ path: at('every', 'eq', 'parent'), message: `"${group}.${column.ref}" cannot be compared with "${parent}.${own.ref}"` });
1580
+ }
1581
+ }
1582
+ return out;
1583
+ }
1584
+ /**
1585
+ * A number a guest types that a money figure Adminium works out reads (a
1586
+ * line's quantity, a stay's guests) is bounded: at least 0 and at most a
1587
+ * stated maximum, or a browser could make a total negative or overflow it.
1588
+ */
1589
+ function boundIssues(index, ctx, table, writable, at) {
1590
+ const out = [];
1591
+ const figures = ctx.figures?.(table);
1592
+ if (figures === undefined)
1593
+ return out;
1594
+ for (const ref of writable) {
1595
+ const column = ruledColumn(index, table, ref);
1596
+ if (!figures.has(ref) || column === undefined || !NUMBERS.includes(column.type))
1597
+ continue;
1598
+ const bounds = column.rules?.validation;
1599
+ if (bounds?.min === undefined || bounds.min < 0 || bounds.max === undefined) {
1600
+ out.push({
1601
+ path: at,
1602
+ message: `"${table}.${ref}" is written by a guest and feeds a price Adminium works out, so it declares validation.min (at least 0) and validation.max`,
1603
+ });
1604
+ }
1605
+ }
1606
+ return out;
1607
+ }
1608
+ /** The rows a create carries (`children`), the checks it runs (`agrees`), and the dry run, price check and retry key. */
1609
+ function treeIssues(entries, ctx, identities, shareCodes) {
1610
+ const out = [];
1611
+ const { index } = ctx;
1612
+ entries.forEach((entry, i) => {
1613
+ const at = (...rest) => ['publicAccess', i, ...rest];
1614
+ if (index.table(entry.table) === undefined)
1615
+ return;
1616
+ const creates = entry.methods.includes('POST');
1617
+ const changes = entry.methods.includes('PATCH');
1618
+ const key = entry.key ?? CUSTOMER_KEY;
1619
+ const writable = entry.writable ?? [];
1620
+ // A guest's own numbers feeding a figure are bounded, on every entry that writes.
1621
+ if (creates || changes)
1622
+ out.push(...boundIssues(index, ctx, entry.table, writable, at('writable')));
1623
+ if (entry.children !== undefined) {
1624
+ if (!creates)
1625
+ out.push({ path: at('children'), message: 'child rows come with a create, and this entry creates nothing' });
1626
+ if (entry.claim !== undefined)
1627
+ out.push({ path: at('children'), message: 'an identity makes no child rows: its person is not made by a create here' });
1628
+ if (entry.visibleWith !== undefined)
1629
+ out.push({ path: at('children'), message: 'an entry visible with a parent is itself a child row; its create carries none' });
1630
+ // Whoever may create without a session proves it once, for the whole tree.
1631
+ const anonymous = entry.claimedBy === undefined || entry.claimedBy.optional === true || entry.identity !== undefined;
1632
+ if (anonymous && entry.humanCheck !== true) {
1633
+ out.push({ path: at('humanCheck'), message: 'a create anyone may make with its child rows asks the human check, once for the whole write' });
1634
+ }
1635
+ }
1636
+ if (entry.dryRun === true && !creates && !changes)
1637
+ out.push({ path: at('dryRun'), message: 'a dry run tries a create or a change, and this entry makes neither' });
1638
+ if (entry.clientKey !== undefined && !creates)
1639
+ out.push({ path: at('clientKey'), message: 'a retry key belongs to a create' });
1640
+ // A row visible with a parent is created alone, not the tree's way: what belongs to a single create's tree would never run there.
1641
+ const alone = entry.visibleWith !== undefined;
1642
+ if (entry.clientKey !== undefined && alone)
1643
+ out.push({ path: at('clientKey'), message: 'a row visible with a parent is created alone, and never looks a retry key up' });
1644
+ if (entry.expect !== undefined && alone && !changes)
1645
+ out.push({ path: at('expect'), message: 'a row visible with a parent is created alone, and never checks a price: a price check here belongs to a change' });
1646
+ if (entry.dryRun === true && alone && !changes)
1647
+ out.push({ path: at('dryRun'), message: 'a row visible with a parent is created alone, and is never tried first: a dry run here belongs to a change' });
1648
+ if (entry.expect !== undefined) {
1649
+ const column = index.column(entry.table, entry.expect);
1650
+ if (!creates && !changes)
1651
+ out.push({ path: at('expect'), message: 'a price check belongs to a create or a change' });
1652
+ if (column === undefined)
1653
+ out.push({ path: at('expect'), message: `"${entry.table}" has no column "${entry.expect}"` });
1654
+ else if (column.type !== 'decimal' && column.type !== 'money')
1655
+ out.push({ path: at('expect'), message: `"${entry.table}.${entry.expect}" is not a money column` });
1656
+ else if (!ctx.decided(entry.table).has(entry.expect))
1657
+ out.push({ path: at('expect'), message: `"${entry.table}.${entry.expect}" is not a figure Adminium works out, so there is nothing to check` });
1658
+ if (!(entry.select ?? []).includes(entry.expect))
1659
+ out.push({ path: at('expect'), message: `"${entry.expect}" is checked, so the entry shows it (select)` });
1660
+ }
1661
+ if (entry.clientKey !== undefined) {
1662
+ const column = ruledColumn(index, entry.table, entry.clientKey);
1663
+ const ref = entry.clientKey;
1664
+ if (column === undefined)
1665
+ out.push({ path: at('clientKey'), message: `"${entry.table}" has no column "${ref}"` });
1666
+ else {
1667
+ if (column.type !== 'text')
1668
+ out.push({ path: at('clientKey'), message: `"${entry.table}.${ref}" is not a text column` });
1669
+ if (column.unique !== true)
1670
+ out.push({ path: at('clientKey'), message: `"${entry.table}.${ref}" finds one row by its key, so it is unique` });
1671
+ }
1672
+ if (!writable.includes(ref))
1673
+ out.push({ path: at('clientKey'), message: `"${ref}" is sent by the browser, so it is writable` });
1674
+ // The key answers the row it made to whoever holds it: never shown, filtered or ordered by.
1675
+ const read = [...(entry.select ?? []), ...(entry.filters ?? []).map((f) => f.column), ...(entry.rank === undefined ? [] : [entry.rank.orderBy])];
1676
+ if (read.includes(ref))
1677
+ out.push({ path: at('clientKey'), message: `"${ref}" is a retry key, so no entry shows, filters or orders by it` });
1678
+ entries.forEach((other, j) => {
1679
+ if (j === i || other.table !== entry.table)
1680
+ return;
1681
+ const shown = [...(other.select ?? []), ...(other.filters ?? []).map((f) => f.column), ...(other.rank === undefined ? [] : [other.rank.orderBy])];
1682
+ if (shown.includes(ref))
1683
+ out.push({ path: ['publicAccess', j, 'select'], message: `"${entry.table}.${ref}" is a retry key, so no entry shows, filters or orders by it` });
1684
+ });
1685
+ }
1686
+ (entry.agrees ?? []).forEach((agree, a) => out.push(...agreeIssues(index, entry.table, undefined, agree, (...rest) => at('agrees', a, ...rest))));
1687
+ if (entry.children === undefined)
1688
+ return;
1689
+ // The people of the key (and a person found by address) are never made as a child row.
1690
+ const people = new Set([identities.get(key)?.entry.table, entry.identity?.table].filter((t) => t !== undefined));
1691
+ const seen = new Set([entry.table]);
1692
+ const walk = (children, parent, level, base) => {
1693
+ for (const [name, child] of Object.entries(children)) {
1694
+ const here = (...rest) => [...base, name, ...rest];
1695
+ const table = ruledTable(index, name);
1696
+ if (table === undefined) {
1697
+ out.push({ path: here(), message: `"${name}" is not a table of this app` });
1698
+ continue;
1699
+ }
1700
+ if (seen.has(name))
1701
+ out.push({ path: here(), message: `"${name}" is in this write twice` });
1702
+ seen.add(name);
1703
+ if (people.has(name))
1704
+ out.push({ path: here(), message: `"${name}" holds the people who sign in, and a create never makes one as a child row` });
1705
+ const via = index.column(name, child.via);
1706
+ if (via?.type !== 'fk' || via.references !== parent)
1707
+ out.push({ path: here('via'), message: `"${name}.${child.via}" does not point at "${parent}"` });
1708
+ const lists = [
1709
+ ['writable', child.writable],
1710
+ ['select', child.select ?? []],
1711
+ ['requires', child.requires ?? []],
1712
+ ['plainText', child.plainText ?? []],
1713
+ ['defaults', Object.keys(child.defaults ?? {})],
1714
+ ['writableValues', Object.keys(child.writableValues ?? {})],
1715
+ ['position', child.position === undefined ? [] : [child.position]],
1716
+ ];
1717
+ for (const [list, refs] of lists) {
1718
+ for (const ref of refs)
1719
+ if (!index.has(name, ref))
1720
+ out.push({ path: here(list), message: `"${name}" has no column "${ref}"` });
1721
+ }
1722
+ // What Adminium fills: its decided columns, the state, the link to the parent, the position, a person's key.
1723
+ const decided = new Set([...ctx.decided(name), child.via, ...(child.position === undefined ? [] : [child.position])]);
1724
+ if (table.states !== undefined)
1725
+ decided.add(table.states.column);
1726
+ for (const ref of child.writable) {
1727
+ if (decided.has(ref))
1728
+ out.push({ path: here('writable'), message: `"${ref}" is decided by Adminium and cannot be written publicly` });
1729
+ const column = index.column(name, ref);
1730
+ if (column?.type === 'fk' && column.references !== undefined && people.has(column.references)) {
1731
+ out.push({ path: here('writable'), message: `"${name}.${ref}" points at the people who sign in, so it is filled by Adminium and never written publicly` });
1732
+ }
1733
+ }
1734
+ for (const ref of child.requires ?? []) {
1735
+ if (!child.writable.includes(ref))
1736
+ out.push({ path: here('requires'), message: `"${ref}" is not writable, so a write cannot fill it` });
1737
+ }
1738
+ for (const [ref, values] of Object.entries(child.writableValues ?? {})) {
1739
+ if (!child.writable.includes(ref))
1740
+ out.push({ path: here('writableValues', ref), message: `"${ref}" is not writable` });
1741
+ const column = index.column(name, ref);
1742
+ if (column !== undefined)
1743
+ for (const value of values) {
1744
+ if (!valueFits(column, value))
1745
+ out.push({ path: here('writableValues', ref), message: `${JSON.stringify(value)} is not a value of "${name}.${ref}"` });
1746
+ }
1747
+ }
1748
+ for (const ref of child.plainText ?? []) {
1749
+ const column = index.column(name, ref);
1750
+ if (column !== undefined && column.type !== 'text')
1751
+ out.push({ path: here('plainText'), message: `"${name}.${ref}" is not a text column` });
1752
+ else if (!child.writable.includes(ref))
1753
+ out.push({ path: here('plainText'), message: `"${ref}" is not writable, so a guest never types it` });
1754
+ }
1755
+ for (const ref of child.select ?? []) {
1756
+ if (shareCodes.get(name)?.has(ref) === true)
1757
+ out.push({ path: here('select'), message: `"${name}.${ref}" is the code a shared link opens its row with: no entry shows, filters or orders by it` });
1758
+ }
1759
+ if (child.position !== undefined) {
1760
+ const column = index.column(name, child.position);
1761
+ if (column !== undefined && column.type !== 'int' && column.type !== 'bigint')
1762
+ out.push({ path: here('position'), message: `"${name}.${child.position}" is not a whole number` });
1763
+ }
1764
+ if (child.min !== undefined && child.min > child.max)
1765
+ out.push({ path: here('min'), message: 'a child table asks for no more rows than it allows' });
1766
+ if (child.sumMax !== undefined) {
1767
+ const sum = child.sumMax;
1768
+ const column = index.column(name, sum.column);
1769
+ if (column === undefined)
1770
+ out.push({ path: here('sumMax', 'column'), message: `"${name}" has no column "${sum.column}"` });
1771
+ else if (!NUMBERS.includes(column.type) || column.type === 'float')
1772
+ out.push({ path: here('sumMax', 'column'), message: `"${name}.${sum.column}" is not a number to add up` });
1773
+ if (typeof sum.max === 'object') {
1774
+ const setting = index.column(sum.max.table, sum.max.column);
1775
+ if (setting === undefined)
1776
+ out.push({ path: here('sumMax', 'max'), message: `"${sum.max.table}" has no column "${sum.max.column}"` });
1777
+ else if (setting.type !== 'int' && setting.type !== 'bigint')
1778
+ out.push({ path: here('sumMax', 'max'), message: `"${sum.max.table}.${sum.max.column}" is not a whole number` });
1779
+ }
1780
+ }
1781
+ out.push(...boundIssues(index, ctx, name, child.writable, here('writable')));
1782
+ (child.agrees ?? []).forEach((agree, a) => out.push(...agreeIssues(index, name, parent, agree, (...rest) => here('agrees', a, ...rest))));
1783
+ (child.counts ?? []).forEach((counts, c) => out.push(...countsIssues(index, name, parent, counts, (...rest) => here('counts', c, ...rest))));
1784
+ if (child.children !== undefined) {
1785
+ if (level >= 2)
1786
+ out.push({ path: here('children'), message: 'child rows go two levels below the create at most' });
1787
+ else
1788
+ walk(child.children, name, level + 1, here('children'));
1789
+ }
1790
+ }
1791
+ };
1792
+ walk(entry.children, entry.table, 1, at('children'));
1793
+ });
1794
+ return out;
1795
+ }
1796
+ /**
1797
+ * The columns of `table` whose value comes, directly or through a formula,
1798
+ * from the row `link` points at: a copy or a nightly price read through it,
1799
+ * a stamp copying one, a formula over one.
1800
+ */
1801
+ function readThrough(table, link) {
1802
+ const out = new Set();
1803
+ for (const column of table.columns) {
1804
+ const rules = column.rules;
1805
+ if (rules?.copy?.via === link || rules?.perNight?.rate.via === link)
1806
+ out.add(column.ref);
1807
+ }
1808
+ for (let changed = true; changed;) {
1809
+ changed = false;
1810
+ for (const column of table.columns) {
1811
+ if (out.has(column.ref))
1812
+ continue;
1813
+ const rules = column.rules;
1814
+ const set = rules?.stamp?.set;
1815
+ const reads = [
1816
+ ...(rules?.formula === undefined ? [] : formulaColumns(rules.formula)),
1817
+ ...(typeof set === 'object' && set !== null && typeof set.copy === 'string' ? [set.copy] : []),
1818
+ ];
1819
+ if (reads.some((ref) => out.has(ref))) {
1820
+ out.add(column.ref);
1821
+ changed = true;
1822
+ }
1823
+ }
1824
+ }
1825
+ return out;
1826
+ }
1827
+ /** A person found by address (`identity`), a row's own link (`shareLink`, `own`), forgetting, and reads for a session alone. */
1828
+ function personIssues(entries, ctx, identities) {
1829
+ const out = [];
1830
+ const { index } = ctx;
1831
+ const ownKeys = new Set([...identities].filter(([, identity]) => identity.entry.claim !== undefined && 'by' in identity.entry.claim && identity.entry.claim.own === true).map(([key]) => key));
1832
+ /** The tables people sign in as by an emailed link, by key. */
1833
+ const linkIdentity = (key) => {
1834
+ const found = identities.get(key)?.entry;
1835
+ return found?.claim !== undefined && claimKind(found.claim) === 'link' ? found : undefined;
1836
+ };
1837
+ entries.forEach((entry, i) => {
1838
+ const at = (...rest) => ['publicAccess', i, ...rest];
1839
+ const table = ruledTable(index, entry.table);
1840
+ if (table === undefined)
1841
+ return;
1842
+ const key = entry.key ?? CUSTOMER_KEY;
1843
+ const writable = entry.writable ?? [];
1844
+ if (entry.identity !== undefined) {
1845
+ const id = entry.identity;
1846
+ const here = (...rest) => at('identity', ...rest);
1847
+ // Found or made on a create; or on a change through the row's own link (a ticket accepted by a friend).
1848
+ const change = ownKeys.has(key) && entry.methods.includes('PATCH') && !entry.methods.includes('POST');
1849
+ if (!change && (entry.methods.length !== 1 || entry.methods[0] !== 'POST')) {
1850
+ out.push({ path: at('methods'), message: 'a person is found by address on a create alone, or on a change through the row\'s own link' });
1851
+ }
1852
+ if (!change && entry.claim !== undefined)
1853
+ out.push({ path: at('identity'), message: 'an identity entry claims its person; it does not find one by address' });
1854
+ if (id.on !== undefined) {
1855
+ const states = table.states;
1856
+ const stateColumn = states === undefined ? undefined : ruledColumn(index, entry.table, states.column);
1857
+ if (!change)
1858
+ out.push({ path: here('on'), message: 'a create finds its person when it is made; "on" names the move of a change through the row\'s own link' });
1859
+ else if (states === undefined || stateColumn === undefined)
1860
+ out.push({ path: here('on'), message: `"${entry.table}" has no states, so no move finds the person` });
1861
+ else {
1862
+ if (!valueFits(stateColumn, id.on.to))
1863
+ out.push({ path: here('on', 'to'), message: `"${id.on.to}" is not a state of "${entry.table}.${states.column}"` });
1864
+ const values = entry.writableValues?.[states.column];
1865
+ if (!writable.includes(states.column) || (values !== undefined && !values.includes(id.on.to))) {
1866
+ out.push({ path: here('on', 'to'), message: `this entry never moves "${entry.table}" to "${id.on.to}", so the person would never be found` });
1867
+ }
1868
+ }
1869
+ }
1870
+ if (entry.visibleWith !== undefined)
1871
+ out.push({ path: at('identity'), message: 'a row visible with a parent belongs to the parent\'s person; it does not find one by address' });
1872
+ // The person signs in by a link to that address: on this key, or (for an own link) on any of the app's keys.
1873
+ const signIn = change
1874
+ ? entries.find((other) => other.table === id.table && other.claim !== undefined && claimKind(other.claim) === 'link')
1875
+ : linkIdentity(key);
1876
+ if (signIn === undefined || signIn.table !== id.table) {
1877
+ out.push({ path: here('table'), message: `a person found by address signs in by a link to it: "${id.table}" needs an identity entry that claims by an emailed link${change ? '' : ` on the "${key}" key`}` });
1878
+ }
1879
+ const people = ruledTable(index, id.table);
1880
+ const email = ruledColumn(index, entry.table, id.email);
1881
+ if (email === undefined)
1882
+ out.push({ path: here('email'), message: `"${entry.table}" has no column "${id.email}"` });
1883
+ else {
1884
+ if (email.type !== 'text')
1885
+ out.push({ path: here('email'), message: `"${entry.table}.${id.email}" is not a text column` });
1886
+ if (email.rules?.validation?.format !== 'email')
1887
+ out.push({ path: here('email'), message: `"${entry.table}.${id.email}" holds an address, so it checks one (validation.format: "email")` });
1888
+ }
1889
+ // On a change through the row's own link, the address may instead be one the link was emailed to: holding the link proves it.
1890
+ const proved = change && ownAddressesOf(identities.get(key)?.entry.claim).includes(id.email);
1891
+ if (!writable.includes(id.email) && !proved)
1892
+ out.push({ path: here('email'), message: `"${id.email}" is typed by the guest, so it is writable` });
1893
+ if (!change && !(entry.requires ?? []).includes(id.email))
1894
+ out.push({ path: here('email'), message: `"${id.email}" finds the person, so a create requires it` });
1895
+ // The person's own address: one row per address, kept trimmed and in lower case, emptied when forgotten.
1896
+ const claimed = signIn?.claim !== undefined && 'email' in signIn.claim ? signIn.claim.email : undefined;
1897
+ const stored = claimed === undefined ? undefined : ruledColumn(index, id.table, claimed);
1898
+ if (stored !== undefined) {
1899
+ if (stored.unique !== true)
1900
+ out.push({ path: here('table'), message: `"${id.table}.${stored.ref}" finds one person by address, so it is unique` });
1901
+ if (stored.rules?.normalize !== 'email')
1902
+ out.push({ path: here('table'), message: `"${id.table}.${stored.ref}" is compared as typed, so it is stored trimmed and in lower case (normalize: "email")` });
1903
+ if (stored.nullable !== true)
1904
+ out.push({ path: here('table'), message: `"${id.table}.${stored.ref}" is emptied when a person is forgotten, so it is nullable` });
1905
+ if (email !== undefined && (stored.maxLength ?? Number.POSITIVE_INFINITY) < (email.maxLength ?? Number.POSITIVE_INFINITY)) {
1906
+ out.push({ path: here('table'), message: `"${id.table}.${stored.ref}" holds fewer characters than "${entry.table}.${id.email}"` });
1907
+ }
1908
+ }
1909
+ const link = index.column(entry.table, id.link);
1910
+ if (link === undefined)
1911
+ out.push({ path: here('link'), message: `"${entry.table}" has no column "${id.link}"` });
1912
+ else {
1913
+ if (link.type !== 'fk' || link.references !== id.table)
1914
+ out.push({ path: here('link'), message: `"${entry.table}.${id.link}" does not point at "${id.table}"` });
1915
+ if (link.nullable !== true)
1916
+ out.push({ path: here('link'), message: `"${entry.table}.${id.link}" stays empty when the address only looks like one on file, so it is nullable` });
1917
+ }
1918
+ // Showing it would say whether the address was already a customer's.
1919
+ if ((entry.select ?? []).includes(id.link))
1920
+ out.push({ path: at('select'), message: `"${id.link}" would tell whether the address was on file, so it is never shown` });
1921
+ if (writable.includes(id.link))
1922
+ out.push({ path: at('writable'), message: `"${id.link}" is filled by Adminium and cannot be written publicly` });
1923
+ if (!change) {
1924
+ const by = entry.claimedBy;
1925
+ if (by === undefined || by.table !== id.table || by.column !== id.link || by.optional !== true) {
1926
+ out.push({ path: at('claimedBy'), message: `a signed-in guest's create links them as it always has: claimedBy {table: "${id.table}", column: "${id.link}", optional: true}` });
1927
+ }
1928
+ if (entry.humanCheck !== true)
1929
+ out.push({ path: at('humanCheck'), message: 'a create that finds a person by address asks the human check' });
1930
+ if (!(entry.anonymous?.perValue?.columns ?? []).includes(id.email)) {
1931
+ out.push({ path: at('anonymous', 'perValue'), message: `a create that finds a person by address is limited per address: anonymous.perValue counts "${id.email}"` });
1932
+ }
1933
+ }
1934
+ if (people !== undefined) {
1935
+ const peopleDecided = ctx.decided(id.table);
1936
+ const filled = new Set([...(claimed === undefined ? [] : [claimed]), ...Object.keys(id.fill ?? {})]);
1937
+ for (const [target, source] of Object.entries(id.fill ?? {})) {
1938
+ const column = ruledColumn(index, id.table, target);
1939
+ if (column === undefined)
1940
+ out.push({ path: here('fill', target), message: `"${id.table}" has no column "${target}"` });
1941
+ else if (column.type !== 'text' || column.role === 'pk' || target === claimed) {
1942
+ out.push({ path: here('fill', target), message: `"${id.table}.${target}" is not a text column a new person is filled with` });
1943
+ }
1944
+ else if (peopleDecided.has(target) || column.rules?.secret === true || shareCodeColumns(entries, id.table).includes(target)) {
1945
+ out.push({ path: here('fill', target), message: `"${id.table}.${target}" is Adminium's or a secret, so a create never fills it` });
1946
+ }
1947
+ else if (column.unique === true) {
1948
+ // A clash would refuse only the create that makes a person: a way to ask whether one exists.
1949
+ out.push({ path: here('fill', target), message: `"${id.table}.${target}" is unique, so filling it would refuse only a new person` });
1950
+ }
1951
+ if (!writable.includes(source))
1952
+ out.push({ path: here('fill', target), message: `"${source}" is not writable, so it holds nothing to fill with` });
1953
+ }
1954
+ // A person made from only what the guest typed: a column a new row could never fill refuses only strangers.
1955
+ for (const column of people.columns) {
1956
+ if (column.role !== undefined || column.nullable === true || column.default !== undefined || peopleDecided.has(column.ref) || filled.has(column.ref))
1957
+ continue;
1958
+ out.push({ path: here('fill'), message: `"${id.table}.${column.ref}" must be given when a person is made, and nothing fills it` });
1959
+ }
1960
+ // Making a person must look like finding one: nothing numbered, counted or mailed on its create.
1961
+ if (people.columns.some((column) => column.rules?.sequence !== undefined)) {
1962
+ out.push({ path: here('table'), message: `"${id.table}" numbers its rows, which a new person would take and a known one would not` });
1963
+ }
1964
+ if (people.capacity !== undefined || people.booking !== undefined) {
1965
+ out.push({ path: here('table'), message: `"${id.table}" carries a limit, which a new person would count against and a known one would not` });
1966
+ }
1967
+ if (ctx.mailsOnCreate?.(id.table) === true) {
1968
+ out.push({ path: here('table'), message: `a message is sent when a "${id.table}" row is made, which would tell a stranger's order apart from its owner's` });
1969
+ }
1970
+ }
1971
+ // Nothing the creating row works out or shows reads the found person: a stranger typing the address would see it.
1972
+ for (const ref of readThrough(table, id.link)) {
1973
+ out.push({ path: at('identity', 'link'), message: `"${entry.table}.${ref}" reads the person "${id.link}" points at, whom a stranger may have typed the address of: nothing on "${entry.table}" reads through it` });
1974
+ }
1975
+ const walkChildren = (children, parent, base) => {
1976
+ for (const [name, child] of Object.entries(children ?? {})) {
1977
+ const own = ruledTable(index, name);
1978
+ if (own === undefined)
1979
+ continue;
1980
+ const tainted = new Set([id.link, ...readThrough(table, id.link)]);
1981
+ for (const ref of child.select ?? []) {
1982
+ const copy = own.columns.find((column) => column.ref === ref)?.rules?.copy;
1983
+ if (copy !== undefined && copy.via === child.via && parent === entry.table && tainted.has(copy.from)) {
1984
+ out.push({ path: [...base, name, 'select'], message: `"${name}.${ref}" copies "${entry.table}.${copy.from}", the person found by address, so it is never shown` });
1985
+ }
1986
+ }
1987
+ walkChildren(child.children, name, [...base, name, 'children']);
1988
+ }
1989
+ };
1990
+ walkChildren(entry.children, entry.table, at('children'));
1991
+ }
1992
+ if (entry.shareLink !== undefined) {
1993
+ const ref = entry.shareLink;
1994
+ if (!entry.methods.includes('POST'))
1995
+ out.push({ path: at('shareLink'), message: 'a row\'s own link is answered by the create that makes it' });
1996
+ const opens = entries.some((other) => other.table === entry.table && other.claim !== undefined && 'by' in other.claim && other.claim.column === ref && other.claim.own === true);
1997
+ if (!opens)
1998
+ out.push({ path: at('shareLink'), message: `"${entry.table}.${ref}" is answered as the row's own link, so a token claim on "${entry.table}" opens by it with own: true` });
1999
+ }
2000
+ if (entry.forget !== undefined) {
2001
+ const claim = entry.claim;
2002
+ const here = (...rest) => at('forget', ...rest);
2003
+ const verifies = claim !== undefined && (claimKind(claim) === 'link' || ('verify' in claim && claim.verify === 'email-code'));
2004
+ if (!verifies)
2005
+ out.push({ path: at('forget'), message: 'a person forgets their details on the identity they sign in with by email' });
2006
+ const decided = ctx.decided(entry.table);
2007
+ for (const ref of entry.forget.columns) {
2008
+ const column = index.column(entry.table, ref);
2009
+ if (column === undefined)
2010
+ out.push({ path: here('columns'), message: `"${entry.table}" has no column "${ref}"` });
2011
+ else if (column.role === 'pk')
2012
+ out.push({ path: here('columns'), message: `"${ref}" is the key of the person, which is kept` });
2013
+ else if (column.nullable !== true && column.type !== 'bool')
2014
+ out.push({ path: here('columns'), message: `"${entry.table}.${ref}" is never empty, so it cannot be forgotten` });
2015
+ else if (decided.has(ref))
2016
+ out.push({ path: here('columns'), message: `"${ref}" is decided by Adminium, which keeps it` });
2017
+ }
2018
+ const address = claim !== undefined && 'email' in claim ? claim.email : undefined;
2019
+ if (address !== undefined && !entry.forget.columns.includes(address)) {
2020
+ out.push({ path: here('columns'), message: `"${address}" signs the person in, so forgetting them empties it` });
2021
+ }
2022
+ if (entry.forget.links === true && ownLinksOfPerson(entries, entry.table).length === 0) {
2023
+ out.push({ path: here('links'), message: `no row a "${entry.table}" person holds opens by its own link, so there is no link to stop` });
2024
+ }
2025
+ if (entry.forget.stamp !== undefined) {
2026
+ const stamp = index.column(entry.table, entry.forget.stamp);
2027
+ if (stamp === undefined)
2028
+ out.push({ path: here('stamp'), message: `"${entry.table}" has no column "${entry.forget.stamp}"` });
2029
+ else if (stamp.type !== 'timestamptz' || stamp.nullable !== true)
2030
+ out.push({ path: here('stamp'), message: `"${entry.table}.${entry.forget.stamp}" is not a nullable timestamptz` });
2031
+ if (entry.forget.columns.includes(entry.forget.stamp))
2032
+ out.push({ path: here('stamp'), message: 'the time a person was forgotten is kept, not emptied' });
2033
+ }
2034
+ }
2035
+ if (entry.newLink !== undefined) {
2036
+ const here = (...rest) => at('newLink', ...rest);
2037
+ const signIn = identities.get(key)?.entry;
2038
+ const verifies = signIn?.claim !== undefined && (claimKind(signIn.claim) === 'link' || ('verify' in signIn.claim && signIn.claim.verify === 'email-code'));
2039
+ // A row's own link asks for another link of its row again: never its own code, which the asking session opened by.
2040
+ const ownLink = entry.claim !== undefined && 'by' in entry.claim && entry.claim.own === true ? entry.claim : undefined;
2041
+ if (ownLink !== undefined) {
2042
+ const others = entries.filter((other) => other.table === entry.table && (other.key ?? CUSTOMER_KEY) !== key && other.claim !== undefined && 'by' in other.claim && other.claim.own === true);
2043
+ if (!entry.methods.includes('GET'))
2044
+ out.push({ path: at('newLink'), message: 'a new link is asked for through a row\'s own link that reads the row (GET)' });
2045
+ if (entry.newLink.column === ownLink.column) {
2046
+ out.push({ path: here('column'), message: `"${entry.table}.${ownLink.column}" is the code this link opens the row by: the session asking would be closed by its own new link` });
2047
+ }
2048
+ else if (!others.some((other) => other.claim.column === entry.newLink.column)) {
2049
+ out.push({ path: here('column'), message: `"${entry.table}.${entry.newLink.column}" is no own link another key opens "${entry.table}" by (a token claim with own: true)` });
2050
+ }
2051
+ }
2052
+ else if (!entry.methods.includes('GET') || entry.claimedBy === undefined || entry.claimedBy.optional === true || !verifies) {
2053
+ out.push({ path: at('newLink'), message: 'a new link is made for a row a person reads signed in by email (claimedBy), or through a row\'s own link, and nowhere else' });
2054
+ }
2055
+ else if (!ownLinksOfPerson(entries, signIn.table).some((link) => link.table === entry.table && link.column === entry.newLink.column)) {
2056
+ out.push({ path: here('column'), message: `"${entry.table}.${entry.newLink.column}" is no own link of a row this person holds (a token claim with own: true)` });
2057
+ }
2058
+ const box = ctx.outbox;
2059
+ if (box === undefined)
2060
+ out.push({ path: here('kind'), message: 'a new link is emailed by the app\'s outbox, and the app has none' });
2061
+ else {
2062
+ if (box.kinds[entry.newLink.kind] === undefined)
2063
+ out.push({ path: here('kind'), message: `"${entry.newLink.kind}" is not one of the outbox's kinds` });
2064
+ if (box.columns.repeatKey === undefined)
2065
+ out.push({ path: here('kind'), message: 'each new link is its own message, so the outbox keeps a repeatKey column' });
2066
+ // Sent by the kind's own rules: never past a person's approval, and never skipped at send as a message already sent.
2067
+ const declared = (box.producers ?? []).find((producer) => producer.kind === entry.newLink.kind);
2068
+ if (declared?.hold === true)
2069
+ out.push({ path: here('kind'), message: `"${entry.newLink.kind}" waits for a person to approve it, so it is never sent again from a link` });
2070
+ if (declared?.repeatBy !== undefined && declared.repeatBy !== entry.newLink.column) {
2071
+ out.push({ path: here('kind'), message: `"${entry.newLink.kind}" is sent once per "${declared.repeatBy}", so one sent again for a new "${entry.newLink.column}" would be skipped: its producer repeats by "${entry.newLink.column}" or by nothing` });
2072
+ }
2073
+ const linked = Object.values(box.links ?? {}).some((column) => index.column(box.table, column)?.references === entry.table);
2074
+ if (!linked)
2075
+ out.push({ path: here('kind'), message: `the outbox links no message to "${entry.table}", so the new link could not be sent about its row` });
2076
+ // Sent to the person who asked for it: the outbox addresses the people this entry's rows are claimed by.
2077
+ if (entry.claimedBy !== undefined && box.recipient !== undefined && box.recipient.table !== entry.claimedBy.table) {
2078
+ out.push({ path: here('kind'), message: `the outbox writes to "${box.recipient.table}", not "${entry.claimedBy.table}" who asks for the new link` });
2079
+ }
2080
+ // Through a row's own link, sent to the row's own address: as the kind's own producer addresses it, from the row alone.
2081
+ if (entry.claimedBy === undefined && entry.claim !== undefined && 'by' in entry.claim && box.recipient !== undefined) {
2082
+ const columnsOf = (at) => index.table(at)?.columns.map((c) => ({ name: c.ref, references: c.references ?? null }));
2083
+ const address = resendAddressOf({ table: box.table, recipient: box.recipient, producers: box.producers }, entry.newLink.kind, entry.table, columnsOf);
2084
+ if (address === null) {
2085
+ out.push({ path: here('kind'), message: `"${entry.newLink.kind}" is not addressed from a "${entry.table}" row (its producer's recipient, the person the row links, or the outbox's fallback through "${entry.table}"), so it cannot be sent again to the row's own address` });
2086
+ }
2087
+ else {
2088
+ // Nobody changes online where it goes: else a link holder could send it to any address in turn.
2089
+ const writers = entries.filter((other) => other.methods.includes('PATCH'));
2090
+ const onRow = writers.filter((other) => other.table === entry.table).flatMap((other) => other.writable ?? []).filter((ref) => address.columns.includes(ref));
2091
+ const onPerson = address.person === undefined ? [] : writers.filter((other) => other.table === address.person.table && (other.writable ?? []).includes(address.person.column)).map(() => `${address.person.table}.${address.person.column}`);
2092
+ for (const ref of [...new Set([...onRow.map((c) => `${entry.table}.${c}`), ...onPerson])]) {
2093
+ out.push({ path: here('kind'), message: `"${ref}" is where it is sent again, and an entry lets a guest change it: nothing a guest writes may say where it goes` });
2094
+ }
2095
+ }
2096
+ }
2097
+ }
2098
+ // What the row must hold: through a row's own link, always said (a confirm email only while it is still to confirm).
2099
+ if (entry.claimedBy === undefined && entry.claim !== undefined && 'by' in entry.claim && entry.claim.own === true && entry.newLink.when === undefined) {
2100
+ out.push({ path: here('when'), message: 'sent again through a row\'s own link only while the row holds a when (an order still to confirm)' });
2101
+ }
2102
+ // Only columns the guest sees: a refusal must tell nothing about one they do not.
2103
+ const shown = entry.select ?? (table.columns.filter((c) => c.rules?.code === undefined && c.rules?.secret !== true).map((c) => c.ref));
2104
+ const withheld = new Set(entry.withhold?.columns ?? []);
2105
+ (entry.newLink.when?.where ?? []).forEach((condition, w) => {
2106
+ out.push(...conditionIssues(entry.table, condition, index, here('when', 'where', w)));
2107
+ if (!shown.includes(condition.column) || withheld.has(condition.column)) {
2108
+ out.push({ path: here('when', 'where', w), message: `"${entry.table}.${condition.column}" is not a column this entry shows, so a new link is never asked for by it` });
2109
+ }
2110
+ });
2111
+ }
2112
+ // A read for the holder of a session alone (no claim of its own): the settings a signed-in guest may see.
2113
+ if (entry.level !== undefined && entry.claim === undefined && entry.claimedBy === undefined && entry.visibleWith === undefined) {
2114
+ if (entry.methods.length !== 1 || entry.methods[0] !== 'GET')
2115
+ out.push({ path: at('methods'), message: 'a read for a signed-in guest alone only reads' });
2116
+ if (!identities.has(key))
2117
+ out.push({ path: at('level'), message: `the "${key}" key signs nobody in, so no session could read this` });
2118
+ if (entry.select === undefined)
2119
+ out.push({ path: at('select'), message: 'a read for a signed-in guest lists what it shows; without select it would show every column' });
2120
+ const signIn = identities.get(key)?.entry.claim;
2121
+ const verifiedOnly = signIn !== undefined && (claimKind(signIn) === 'link' || ('own' in signIn && signIn.own === true));
2122
+ if (verifiedOnly && entry.level !== 'verified')
2123
+ out.push({ path: at('level'), message: `the "${key}" key's sessions are verified, so this entry is read at level "verified"` });
2124
+ }
2125
+ // The owner's own link: it changes its row and its children, within what each entry names.
2126
+ if (ownKeys.has(key)) {
2127
+ if (entry.methods.includes('PATCH') && entry.writable === undefined)
2128
+ out.push({ path: at('writable'), message: 'a change through a row\'s own link names what it may write' });
2129
+ if (entry.methods.includes('POST') && entry.visibleWith === undefined)
2130
+ out.push({ path: at('methods'), message: 'a row\'s own link makes only child rows of the row it opens (visibleWith)' });
2131
+ if (entry.methods.includes('PATCH')) {
2132
+ for (const ref of writable) {
2133
+ if (index.column(entry.table, ref)?.type === 'enum' && entry.writableValues?.[ref] === undefined) {
2134
+ out.push({ path: at('writableValues'), message: `"${ref}" is an enum a browser changes; list the values it may set` });
2135
+ }
2136
+ }
2137
+ }
2138
+ if (entry.visibleWith !== undefined && entry.level !== 'verified') {
2139
+ out.push({ path: at('level'), message: `the "${key}" key opens a row's own link at level "verified", so this entry is read at it` });
2140
+ }
2141
+ }
2142
+ });
2143
+ // An own link's code, its end and its stop are never written through any entry.
2144
+ for (const key of ownKeys) {
2145
+ const claim = identities.get(key).entry.claim;
2146
+ const table = identities.get(key).entry.table;
2147
+ const guarded = [claim.column, claim.expires, claim.stopped].filter((ref) => ref !== undefined);
2148
+ entries.forEach((entry, i) => {
2149
+ if (entry.table !== table)
2150
+ return;
2151
+ for (const ref of guarded) {
2152
+ if ((entry.writable ?? []).includes(ref) || Object.prototype.hasOwnProperty.call(entry.writableValues ?? {}, ref)) {
2153
+ out.push({ path: ['publicAccess', i, 'writable'], message: `"${table}.${ref}" opens or closes the row's own link, so no browser writes it` });
2154
+ }
2155
+ }
2156
+ });
2157
+ }
2158
+ return out;
2159
+ }
2160
+ /** Whether a code's `renew` names a change of `ref` among its triggers. */
2161
+ function renewsOn(renew, ref) {
2162
+ if (renew === undefined)
2163
+ return false;
2164
+ const triggers = (Array.isArray(renew.on) ? renew.on : [renew.on]);
2165
+ return triggers.some((trigger) => trigger.column === ref && trigger.changed === true);
2166
+ }
2167
+ /** The columns a row's own link may be emailed to, as a list (none for any other claim). */
2168
+ function ownAddressesOf(claim) {
2169
+ if (claim === undefined || !('by' in claim) || claim.own !== true || claim.address === undefined)
2170
+ return [];
2171
+ return typeof claim.address === 'string' ? [claim.address] : claim.address;
2172
+ }
2173
+ /**
2174
+ * Where a row's own link may be emailed (`claim.address`), and the columns
2175
+ * left out of rows read through a parent (`withhold`).
2176
+ */
2177
+ function ownAddressAndWithholdIssues(entries, ctx, identities) {
2178
+ const out = [];
2179
+ const { index } = ctx;
2180
+ /** Whether an entry lets a browser set a column. */
2181
+ const writes = (entry, ref) => (entry.writable ?? []).includes(ref) ||
2182
+ Object.prototype.hasOwnProperty.call(entry.writableValues ?? {}, ref) ||
2183
+ Object.prototype.hasOwnProperty.call(entry.defaults ?? {}, ref);
2184
+ /** The tables a key signs a person in on: an identity that is not a token (a token opens a row, not a person). */
2185
+ const people = new Set([...identities.values()].filter(({ entry }) => entry.claim !== undefined && claimKind(entry.claim) !== 'token').map(({ entry }) => entry.table));
2186
+ entries.forEach((entry, i) => {
2187
+ const at = (...rest) => ['publicAccess', i, ...rest];
2188
+ if (index.table(entry.table) === undefined)
2189
+ return;
2190
+ const key = entry.key ?? CUSTOMER_KEY;
2191
+ const column = (ref) => index.column(entry.table, ref);
2192
+ const claim = entry.claim;
2193
+ if (claim !== undefined && 'by' in claim && claim.address !== undefined) {
2194
+ const refs = typeof claim.address === 'string' ? [claim.address] : claim.address;
2195
+ if (claim.own !== true)
2196
+ out.push({ path: at('claim', 'address'), message: "a link is emailed to its row's own address only when it is the row's own link (own: true)" });
2197
+ if (refs.length === 2 && refs[0] === refs[1])
2198
+ out.push({ path: at('claim', 'address'), message: `"${refs[0]}" is named twice` });
2199
+ for (const ref of new Set(refs)) {
2200
+ const found = column(ref);
2201
+ if (found === undefined)
2202
+ out.push({ path: at('claim', 'address'), message: `"${entry.table}" has no column "${ref}"` });
2203
+ else if (found.type !== 'text')
2204
+ out.push({ path: at('claim', 'address'), message: `"${entry.table}.${ref}" is not a text column holding an address` });
2205
+ else if (ref === claim.column)
2206
+ out.push({ path: at('claim', 'address'), message: `"${entry.table}.${ref}" is the link's secret, not an address` });
2207
+ // Whoever holds the link could otherwise send it on to an address of their choosing.
2208
+ entries.forEach((other, j) => {
2209
+ if (other.table === entry.table && (other.key ?? CUSTOMER_KEY) === key && writes(other, ref)) {
2210
+ out.push({ path: ['publicAccess', j, 'writable'], message: `"${entry.table}.${ref}" is an address the row's own link is emailed to, so a change through that link may not set it` });
2211
+ }
2212
+ });
2213
+ /*
2214
+ * An address another entry's change writes (the buyer sends a ticket on
2215
+ * to a friend): the link must be new each time it goes to a new address,
2216
+ * or whoever it was sent to before still holds a link to the row.
2217
+ */
2218
+ const changedBy = entries.findIndex((other) => other.table === entry.table && other.methods.some((m) => m === 'PATCH') && writes(other, ref));
2219
+ const codeColumn = ruledColumn(index, entry.table, claim.column);
2220
+ if (found !== undefined && changedBy >= 0 && !renewsOn(codeColumn?.rules?.code?.renew, ref)) {
2221
+ out.push({
2222
+ path: at('claim', 'address'),
2223
+ message: `"${entry.table}.${ref}" is written by a change (publicAccess ${String(changedBy)}) and the row's own link is emailed to it, so "${entry.table}.${claim.column}" renews when it changes: rules.code.renew.on {"column": "${ref}", "changed": true}`,
2224
+ });
2225
+ }
2226
+ }
2227
+ }
2228
+ const withhold = entry.withhold;
2229
+ if (withhold === undefined)
2230
+ return;
2231
+ // A row's own link (a ticket a friend has not taken yet) holds back columns while a condition holds: it names nobody, so no holder.
2232
+ const ownLink = claim !== undefined && claimKind(claim) === 'token' && 'own' in claim && claim.own === true;
2233
+ if (ownLink && withhold.unlessHolder !== undefined) {
2234
+ out.push({ path: at('withhold', 'unlessHolder'), message: "a row's own link names nobody, so it holds columns back by when alone" });
2235
+ }
2236
+ else if (!ownLink && entry.visibleWith === undefined && entry.claimedBy === undefined) {
2237
+ out.push({ path: at('withhold'), message: "columns are withheld from rows read through a parent (visibleWith or claimedBy), or by a row's own link while a condition holds" });
2238
+ }
2239
+ else if (entry.claimedBy !== undefined && entry.claimedBy.column === withhold.unlessHolder) {
2240
+ out.push({ path: at('withhold', 'unlessHolder'), message: `"${withhold.unlessHolder}" is the column this entry is claimed by, so every row it reads is already its holder's` });
2241
+ }
2242
+ if (new Set(withhold.columns).size !== withhold.columns.length)
2243
+ out.push({ path: at('withhold', 'columns'), message: 'a column is withheld once' });
2244
+ for (const ref of withhold.columns) {
2245
+ if (entry.select === undefined || !entry.select.includes(ref))
2246
+ out.push({ path: at('withhold', 'columns'), message: `"${ref}" is not one of the columns the entry shows` });
2247
+ }
2248
+ // What a `when` reads decides who sees the columns: never a browser's to write.
2249
+ const ruled = ruledTable(index, entry.table);
2250
+ if (withhold.when !== undefined && ruled !== undefined) {
2251
+ (withhold.when.where ?? []).forEach((condition, w) => {
2252
+ out.push(...conditionIssues(entry.table, condition, index, at('withhold', 'when', 'where', w)));
2253
+ entries.forEach((other, j) => {
2254
+ if (other.table === entry.table && writes(other, condition.column)) {
2255
+ out.push({ path: ['publicAccess', j, 'writable'], message: `"${entry.table}.${condition.column}" decides who reads the withheld columns, so no browser writes it` });
2256
+ }
2257
+ });
2258
+ });
2259
+ (withhold.when.linked ?? []).forEach((link, l) => {
2260
+ const via = column(link.via);
2261
+ const target = via?.type === 'fk' && via.references !== undefined ? ruledTable(index, via.references) : undefined;
2262
+ if (target === undefined) {
2263
+ out.push({ path: at('withhold', 'when', 'linked', l, 'via'), message: `"${entry.table}.${link.via}" is not a foreign key of this app` });
2264
+ return;
2265
+ }
2266
+ link.where.forEach((condition, w) => {
2267
+ out.push(...conditionIssues(target.ref, condition, index, at('withhold', 'when', 'linked', l, 'where', w)));
2268
+ entries.forEach((other, j) => {
2269
+ if (other.table === target.ref && writes(other, condition.column)) {
2270
+ out.push({ path: ['publicAccess', j, 'writable'], message: `"${target.ref}.${condition.column}" decides who reads "${entry.table}"'s withheld columns, so no browser writes it` });
2271
+ }
2272
+ });
2273
+ });
2274
+ entries.forEach((other, j) => {
2275
+ // A child's create names the parent it is made under (one the guest reaches): that is no change of who reads it.
2276
+ const createsUnder = other.visibleWith?.via === link.via && !other.methods.includes('PATCH');
2277
+ if (other.table === entry.table && writes(other, link.via) && !createsUnder) {
2278
+ out.push({ path: ['publicAccess', j, 'writable'], message: `"${entry.table}.${link.via}" decides who reads the withheld columns, so no browser writes it` });
2279
+ }
2280
+ });
2281
+ });
2282
+ }
2283
+ const holder = withhold.unlessHolder;
2284
+ if (holder === undefined)
2285
+ return;
2286
+ const link = column(holder);
2287
+ const root = rootIdentity(entries, i, identities);
2288
+ if (link === undefined) {
2289
+ out.push({ path: at('withhold', 'unlessHolder'), message: `"${entry.table}" has no column "${holder}"` });
2290
+ }
2291
+ else if (link.type !== 'fk') {
2292
+ out.push({ path: at('withhold', 'unlessHolder'), message: `"${entry.table}.${holder}" is not a foreign key to a person` });
2293
+ }
2294
+ else if (root?.claim !== undefined && claimKind(root.claim) === 'token') {
2295
+ // A key that opens a row by its link signs no person in: the columns are withheld whenever the holder is set.
2296
+ if (!people.has(link.references ?? '')) {
2297
+ out.push({ path: at('withhold', 'unlessHolder'), message: `"${entry.table}.${holder}" does not point at a person any key signs in` });
2298
+ }
2299
+ }
2300
+ else if (root !== undefined && link.references !== root.table) {
2301
+ out.push({ path: at('withhold', 'unlessHolder'), message: `"${entry.table}.${holder}" does not point at "${root.table}", the person the "${key}" key signs in` });
2302
+ }
2303
+ // Who may read the withheld columns is never a browser's to say.
2304
+ entries.forEach((other, j) => {
2305
+ if (other.table === entry.table && writes(other, holder)) {
2306
+ out.push({ path: ['publicAccess', j, 'writable'], message: `"${entry.table}.${holder}" decides who reads the withheld columns, so no browser writes it` });
2307
+ }
2308
+ });
2309
+ });
2310
+ return out;
2311
+ }
731
2312
  //# sourceMappingURL=public-access.js.map