@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.
- package/dist/capacity.d.ts +786 -0
- package/dist/capacity.d.ts.map +1 -0
- package/dist/capacity.js +717 -0
- package/dist/capacity.js.map +1 -0
- package/dist/documents.d.ts +91 -5
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +129 -32
- package/dist/documents.js.map +1 -1
- package/dist/formula.d.ts +114 -2
- package/dist/formula.d.ts.map +1 -1
- package/dist/formula.js +291 -5
- package/dist/formula.js.map +1 -1
- package/dist/index.d.ts +12 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -6
- package/dist/index.js.map +1 -1
- package/dist/marketplace-wire.d.ts +230 -0
- package/dist/marketplace-wire.d.ts.map +1 -0
- package/dist/marketplace-wire.js +138 -0
- package/dist/marketplace-wire.js.map +1 -0
- package/dist/outbox.d.ts +242 -27
- package/dist/outbox.d.ts.map +1 -1
- package/dist/outbox.js +553 -27
- package/dist/outbox.js.map +1 -1
- package/dist/plan-context.d.ts +28 -3
- package/dist/plan-context.d.ts.map +1 -1
- package/dist/plan-context.js +291 -14
- package/dist/plan-context.js.map +1 -1
- package/dist/plan-model.d.ts +58 -3
- package/dist/plan-model.d.ts.map +1 -1
- package/dist/public-access.d.ts +637 -0
- package/dist/public-access.d.ts.map +1 -1
- package/dist/public-access.js +1593 -12
- package/dist/public-access.js.map +1 -1
- package/dist/refs.d.ts +86 -0
- package/dist/refs.d.ts.map +1 -1
- package/dist/refs.js +186 -0
- package/dist/refs.js.map +1 -1
- package/dist/roles.d.ts +10 -2
- package/dist/roles.d.ts.map +1 -1
- package/dist/roles.js +27 -5
- package/dist/roles.js.map +1 -1
- package/dist/sample.d.ts +218 -2
- package/dist/sample.d.ts.map +1 -1
- package/dist/sample.js +145 -7
- package/dist/sample.js.map +1 -1
- package/dist/schema.d.ts +2860 -360
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +1002 -54
- package/dist/schema.js.map +1 -1
- package/dist/shapes.d.ts +5 -1
- package/dist/shapes.d.ts.map +1 -1
- package/dist/shapes.js +13 -3
- package/dist/shapes.js.map +1 -1
- package/dist/states.d.ts +342 -2
- package/dist/states.d.ts.map +1 -1
- package/dist/states.js +566 -7
- package/dist/states.js.map +1 -1
- package/dist/table-shapes.d.ts +47 -0
- package/dist/table-shapes.d.ts.map +1 -0
- package/dist/table-shapes.js +159 -0
- package/dist/table-shapes.js.map +1 -0
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +80 -2
- package/dist/validate.js.map +1 -1
- package/package.json +2 -2
package/dist/public-access.js
CHANGED
|
@@ -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
|
-
/**
|
|
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({
|
|
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
|
-
|
|
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
|
|
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' &&
|
|
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
|
-
|
|
578
|
-
|
|
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
|
|
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
|
|
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
|