@adminiumjs/manifest 0.3.0-rc.4 → 0.3.1
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/add-ons.d.ts +63 -0
- package/dist/add-ons.d.ts.map +1 -0
- package/dist/add-ons.js +100 -0
- package/dist/add-ons.js.map +1 -0
- package/dist/booking.d.ts +100 -0
- package/dist/booking.d.ts.map +1 -0
- package/dist/booking.js +237 -0
- package/dist/booking.js.map +1 -0
- package/dist/documents.d.ts +100 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +148 -0
- package/dist/documents.js.map +1 -0
- package/dist/formula.d.ts +124 -0
- package/dist/formula.d.ts.map +1 -0
- package/dist/formula.js +428 -0
- package/dist/formula.js.map +1 -0
- package/dist/index.d.ts +14 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -3
- package/dist/index.js.map +1 -1
- package/dist/outbox.d.ts +543 -0
- package/dist/outbox.d.ts.map +1 -0
- package/dist/outbox.js +559 -0
- package/dist/outbox.js.map +1 -0
- package/dist/page-calendar.d.ts +42 -0
- package/dist/page-calendar.d.ts.map +1 -0
- package/dist/page-calendar.js +95 -0
- package/dist/page-calendar.js.map +1 -0
- package/dist/plan-model.d.ts +1 -1
- package/dist/plan-model.d.ts.map +1 -1
- package/dist/public-access.d.ts +208 -0
- package/dist/public-access.d.ts.map +1 -0
- package/dist/public-access.js +681 -0
- package/dist/public-access.js.map +1 -0
- package/dist/refs.d.ts +91 -0
- package/dist/refs.d.ts.map +1 -0
- package/dist/refs.js +82 -0
- package/dist/refs.js.map +1 -0
- package/dist/roles.d.ts +44 -0
- package/dist/roles.d.ts.map +1 -0
- package/dist/roles.js +75 -0
- package/dist/roles.js.map +1 -0
- package/dist/sample.d.ts +150 -2
- package/dist/sample.d.ts.map +1 -1
- package/dist/sample.js +101 -3
- package/dist/sample.js.map +1 -1
- package/dist/schema.d.ts +3464 -151
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +681 -143
- package/dist/schema.js.map +1 -1
- package/dist/semver.d.ts +28 -0
- package/dist/semver.d.ts.map +1 -0
- package/dist/semver.js +85 -0
- package/dist/semver.js.map +1 -0
- package/dist/shapes.d.ts +97 -0
- package/dist/shapes.d.ts.map +1 -0
- package/dist/shapes.js +190 -0
- package/dist/shapes.js.map +1 -0
- package/dist/states.d.ts +134 -0
- package/dist/states.d.ts.map +1 -0
- package/dist/states.js +244 -0
- package/dist/states.js.map +1 -0
- package/dist/validate.d.ts +14 -0
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +29 -2
- package/dist/validate.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
/**
|
|
3
|
+
* `publicAccess` — what the app's public screens may do with one table, and
|
|
4
|
+
* `publicKeys`, the browser keys beyond the one every app gets.
|
|
5
|
+
*
|
|
6
|
+
* An entry is served through a browser key the install creates: `customer`
|
|
7
|
+
* (the default, the app's public side) or one named in `publicKeys`. `GET`
|
|
8
|
+
* reads, `POST` creates; `PATCH` only behind a claim, on a narrow writable
|
|
9
|
+
* list. `availability` answers free or full per slot and never a row.
|
|
10
|
+
*
|
|
11
|
+
* CLAIMS. An entry with `claim` is the key's IDENTITY: proving you know a
|
|
12
|
+
* row's details (a mobile and a date of birth) opens a session on that row.
|
|
13
|
+
* An entry with `claimedBy` is a child of that identity: it reaches only the
|
|
14
|
+
* rows whose column holds the claimed row's key. `verify: 'email-code'` adds a
|
|
15
|
+
* second step, a code emailed to the row's own address, which raises the
|
|
16
|
+
* session from `lookup` to `verified`; an entry that says `level: 'verified'`
|
|
17
|
+
* refuses a session that has not taken it.
|
|
18
|
+
*
|
|
19
|
+
* Two more ways to claim. `verify: 'email-link'`: the person types only
|
|
20
|
+
* their address and Adminium emails a one-use link (and a code for another
|
|
21
|
+
* device); the session opens at `verified` from the link, and a typed
|
|
22
|
+
* address alone opens nothing. `by: 'token'`: an unguessable code in a
|
|
23
|
+
* column opens that one row, with no email at all (a handover page shared by
|
|
24
|
+
* link) — on a key of its own, since a key has one identity.
|
|
25
|
+
*
|
|
26
|
+
* An entry with `visibleWith` reaches a child's rows only where the parent
|
|
27
|
+
* entry reaches the parent row: a draft's lines stay as hidden as the draft.
|
|
28
|
+
*/
|
|
29
|
+
import { z } from 'zod';
|
|
30
|
+
import { refSchema, scalarSchema, settingRefSchema, valueFits, } from './refs.js';
|
|
31
|
+
/** The key every entry uses unless it names another. */
|
|
32
|
+
export const CUSTOMER_KEY = 'customer';
|
|
33
|
+
const keyNameSchema = z.string().regex(/^[a-z][a-z0-9-]{0,31}$/, 'a key name is kebab-case');
|
|
34
|
+
const valueFilterSchema = z
|
|
35
|
+
.object({
|
|
36
|
+
column: refSchema,
|
|
37
|
+
op: z.enum(['eq', 'neq', 'in', 'gte', 'lte']),
|
|
38
|
+
value: z.union([z.string(), z.number(), z.boolean(), z.array(z.union([z.string(), z.number()]))]),
|
|
39
|
+
})
|
|
40
|
+
.strict();
|
|
41
|
+
/**
|
|
42
|
+
* A filter against the venue's own calendar, worked out on every request in
|
|
43
|
+
* the venue's zone: `today` is today's date, `from-today` today onwards
|
|
44
|
+
* (`days` limits how far).
|
|
45
|
+
*/
|
|
46
|
+
const relativeFilterSchema = z.union([
|
|
47
|
+
z.object({ column: refSchema, op: z.literal('today') }).strict(),
|
|
48
|
+
z.object({ column: refSchema, op: z.literal('from-today'), days: z.number().int().min(1).max(366).optional() }).strict(),
|
|
49
|
+
]);
|
|
50
|
+
const valuesSchema = z.array(scalarSchema).min(1).max(32);
|
|
51
|
+
/** The states a row may be changed from: values, and `null` for "still empty". */
|
|
52
|
+
const whenValuesSchema = z.array(z.union([scalarSchema, z.null()])).min(1).max(32);
|
|
53
|
+
/**
|
|
54
|
+
* Proving you know a row's details, e.g. `{match: [code, mobile]}`. With
|
|
55
|
+
* `verify`, a code sent to the row's `email` column raises the session to
|
|
56
|
+
* `verified`.
|
|
57
|
+
*/
|
|
58
|
+
const lookupClaimSchema = z
|
|
59
|
+
.object({
|
|
60
|
+
match: z.array(refSchema).min(1).max(3),
|
|
61
|
+
verify: z.literal('email-code').optional(),
|
|
62
|
+
email: refSchema.optional(),
|
|
63
|
+
})
|
|
64
|
+
.strict()
|
|
65
|
+
.refine((c) => (c.verify === undefined) === (c.email === undefined), {
|
|
66
|
+
message: 'an emailed code names the column holding the address, and only it does',
|
|
67
|
+
path: ['email'],
|
|
68
|
+
});
|
|
69
|
+
/** Signing in by a link emailed to the address in `email`. */
|
|
70
|
+
const linkClaimSchema = z.object({ verify: z.literal('email-link'), email: refSchema }).strict();
|
|
71
|
+
/** Opening one row by the code in `column`, while it has not expired or been stopped. */
|
|
72
|
+
const tokenClaimSchema = z
|
|
73
|
+
.object({ by: z.literal('token'), column: refSchema, expires: refSchema.optional(), stopped: refSchema.optional() })
|
|
74
|
+
.strict();
|
|
75
|
+
export const claimSchema = z.union([lookupClaimSchema, linkClaimSchema, tokenClaimSchema]);
|
|
76
|
+
/** How an identity entry claims: by details, by an emailed link, or by a token. */
|
|
77
|
+
export function claimKind(claim) {
|
|
78
|
+
if ('by' in claim)
|
|
79
|
+
return 'token';
|
|
80
|
+
if ('match' in claim)
|
|
81
|
+
return 'lookup';
|
|
82
|
+
return 'link';
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* A time no more than `within` minutes ahead — a past time always passes. A
|
|
86
|
+
* kiosk takes an arrival from an hour before the visit, and a late one too.
|
|
87
|
+
* Up to a day: a longer window is no window.
|
|
88
|
+
*/
|
|
89
|
+
const timeWindowSchema = z.object({ within: z.number().int().min(1).max(1440) }).strict();
|
|
90
|
+
export const publicAccessSchema = z
|
|
91
|
+
.object({
|
|
92
|
+
table: refSchema,
|
|
93
|
+
kind: z.enum(['records', 'availability']).optional(),
|
|
94
|
+
methods: z.array(z.enum(['GET', 'POST', 'PATCH'])).min(1),
|
|
95
|
+
select: z.array(refSchema).optional(),
|
|
96
|
+
writable: z.array(refSchema).optional(),
|
|
97
|
+
filters: z.array(z.union([valueFilterSchema, relativeFilterSchema])).optional(),
|
|
98
|
+
/** Values the server writes, whatever the browser sends (`status: confirmed`). */
|
|
99
|
+
defaults: z.record(refSchema, z.union([z.string(), z.number(), z.boolean()])).optional(),
|
|
100
|
+
/** How a person proves who they are on this key (see {@link claimSchema}). */
|
|
101
|
+
claim: claimSchema.optional(),
|
|
102
|
+
/**
|
|
103
|
+
* The rows of a claimed person: `column` holds the key of the row the key's
|
|
104
|
+
* identity entry on `table` claims. `optional` on a create lets it go
|
|
105
|
+
* through with no session at all (a first visit, by someone not yet on file).
|
|
106
|
+
*/
|
|
107
|
+
claimedBy: z.object({ table: refSchema, column: refSchema, optional: z.literal(true).optional() }).strict().optional(),
|
|
108
|
+
/** The session this entry needs: `verified` once the emailed code is confirmed. */
|
|
109
|
+
level: z.enum(['lookup', 'verified']).optional(),
|
|
110
|
+
/**
|
|
111
|
+
* Rows readable only where a parent entry (the one on `table`, on the same
|
|
112
|
+
* key) reads the row they belong to. `via` is this table's foreign key to
|
|
113
|
+
* the parent, or the parent's foreign key to this table.
|
|
114
|
+
*/
|
|
115
|
+
visibleWith: z.object({ table: refSchema, via: refSchema }).strict().optional(),
|
|
116
|
+
/** File columns a claimed person may download (the file the row names, never by its id). */
|
|
117
|
+
files: z.array(refSchema).min(1).max(8).optional(),
|
|
118
|
+
/** The kinds of document a claimed person may list and open for these rows. */
|
|
119
|
+
documents: z.array(z.string().regex(/^[a-z][a-z0-9-]*$/, 'a document kind')).min(1).max(8).optional(),
|
|
120
|
+
/**
|
|
121
|
+
* Rows a person must prove more to see. An identity marked sensitive shows
|
|
122
|
+
* only its own `select` on a lookup; each entry it claims must then say
|
|
123
|
+
* whether it is sensitive itself — and, when it is not, `reason` why not.
|
|
124
|
+
*/
|
|
125
|
+
sensitive: z.boolean().optional(),
|
|
126
|
+
reason: z.string().min(1).max(200).optional(),
|
|
127
|
+
/** On an optional claim, the columns a session's create empties (the details of a first visit). */
|
|
128
|
+
onClaim: z.object({ clear: z.array(refSchema).min(1).max(12) }).strict().optional(),
|
|
129
|
+
/** The only values a browser may write into these columns. */
|
|
130
|
+
writableValues: z.record(refSchema, valuesSchema).optional(),
|
|
131
|
+
/**
|
|
132
|
+
* Columns a write through this entry must fill: accepting a proposal
|
|
133
|
+
* carries the name typed as a signature, and a write without it is
|
|
134
|
+
* refused rather than stored half.
|
|
135
|
+
*/
|
|
136
|
+
requires: z.array(refSchema).min(1).max(8).optional(),
|
|
137
|
+
/**
|
|
138
|
+
* The state a row must be IN to be changed — part of the update itself,
|
|
139
|
+
* never of a read, so a finished visit still lists but cannot be moved.
|
|
140
|
+
* `from-now` on a time: only while it is still ahead. `{within: 60}` on a
|
|
141
|
+
* time: no more than 60 minutes ahead, and a change made earlier is
|
|
142
|
+
* refused with that time (`PUBLIC_TOO_EARLY`), even when `select` leaves
|
|
143
|
+
* it out — naming the window is agreeing to that. On a date,
|
|
144
|
+
* `from-today` is today or later on the venue's calendar and
|
|
145
|
+
* `before-today` is past (a proposal still in date may be accepted; one
|
|
146
|
+
* past it may ask for a new price).
|
|
147
|
+
*/
|
|
148
|
+
writableWhen: z
|
|
149
|
+
.record(refSchema, z.union([whenValuesSchema, z.literal('from-now'), z.literal('from-today'), z.literal('before-today'), timeWindowSchema]))
|
|
150
|
+
.optional(),
|
|
151
|
+
/** A proof-of-work the browser solves before the write (or the claim) is taken. */
|
|
152
|
+
humanCheck: z.literal(true).optional(),
|
|
153
|
+
/** A create answers where the new row stands: the rows ordered at or before it. */
|
|
154
|
+
rank: z
|
|
155
|
+
.object({ orderBy: refSchema, where: z.object({ column: refSchema, eq: scalarSchema }).strict().optional() })
|
|
156
|
+
.strict()
|
|
157
|
+
.optional(),
|
|
158
|
+
/** A claimed person may hold at most `n` rows whose column is one of `values` (and, with `upcoming`, still ahead). */
|
|
159
|
+
maxOpen: z
|
|
160
|
+
.object({ column: refSchema, values: valuesSchema, n: z.number().int().min(1).max(50), upcoming: refSchema.optional() })
|
|
161
|
+
.strict()
|
|
162
|
+
.optional(),
|
|
163
|
+
/**
|
|
164
|
+
* The limits on a create nobody signed in for: per phone number or
|
|
165
|
+
* address a day, per key an hour, and columns that hold plain text only.
|
|
166
|
+
*/
|
|
167
|
+
anonymous: z
|
|
168
|
+
.object({
|
|
169
|
+
perValue: z.object({ columns: z.array(refSchema).min(1).max(4), n: z.number().int().min(1).max(20) }).strict().optional(),
|
|
170
|
+
perKeyHour: z.number().int().min(1).max(1000).optional(),
|
|
171
|
+
plainText: z.array(refSchema).min(1).max(8).optional(),
|
|
172
|
+
})
|
|
173
|
+
.strict()
|
|
174
|
+
.optional(),
|
|
175
|
+
/** Refused while a bool in the settings row is false (only for a session-less create with `anonymous`). */
|
|
176
|
+
requireSetting: z
|
|
177
|
+
.array(settingRefSchema.extend({ when: z.literal('anonymous').optional() }).strict())
|
|
178
|
+
.max(4)
|
|
179
|
+
.optional(),
|
|
180
|
+
/** Which browser key serves the entry: `customer`, or one of `publicKeys`. */
|
|
181
|
+
key: keyNameSchema.optional(),
|
|
182
|
+
/**
|
|
183
|
+
* The confirmation Adminium emails when a guest creates a row here: the
|
|
184
|
+
* column holding their address, the columns the email shows, the app's
|
|
185
|
+
* one-row venue table, and the path under the guest side that manages it.
|
|
186
|
+
*/
|
|
187
|
+
confirm: z
|
|
188
|
+
.object({
|
|
189
|
+
template: z.enum(['booking-confirmation']),
|
|
190
|
+
to: refSchema,
|
|
191
|
+
code: refSchema.optional(),
|
|
192
|
+
when: refSchema.optional(),
|
|
193
|
+
party: refSchema.optional(),
|
|
194
|
+
name: refSchema.optional(),
|
|
195
|
+
venue: z
|
|
196
|
+
.object({ table: refSchema, name: refSchema.optional(), address: refSchema.optional(), phone: refSchema.optional() })
|
|
197
|
+
.strict()
|
|
198
|
+
.optional(),
|
|
199
|
+
link: z.string().max(200).optional(),
|
|
200
|
+
})
|
|
201
|
+
.strict()
|
|
202
|
+
.optional(),
|
|
203
|
+
})
|
|
204
|
+
.strict();
|
|
205
|
+
/**
|
|
206
|
+
* A browser key besides `customer`. It is never published to the app's public
|
|
207
|
+
* side: the staff side hands it only to someone signed in with `role`, and
|
|
208
|
+
* every request on it must carry that person's staff session too — a token
|
|
209
|
+
* copied out of the page opens nothing on its own. `enabledBy` switches it
|
|
210
|
+
* off with a bool in the settings row.
|
|
211
|
+
*/
|
|
212
|
+
export const publicKeySchema = z
|
|
213
|
+
.object({
|
|
214
|
+
/**
|
|
215
|
+
* Required, except on a key whose identity is a token: that key opens one
|
|
216
|
+
* row to whoever holds its link, and reads nothing else.
|
|
217
|
+
*/
|
|
218
|
+
requiresStaff: z.object({ role: z.string().regex(/^[a-z][a-z0-9-]*$/, 'a role key') }).strict().optional(),
|
|
219
|
+
enabledBy: settingRefSchema.optional(),
|
|
220
|
+
})
|
|
221
|
+
.strict();
|
|
222
|
+
export const publicKeysSchema = z
|
|
223
|
+
.record(keyNameSchema, publicKeySchema)
|
|
224
|
+
.refine((keys) => !(CUSTOMER_KEY in keys), { message: '"customer" is the app\'s own key and is not declared here' });
|
|
225
|
+
/** Everything in `publicAccess` and `publicKeys` that names something undeclared, or breaks a rule. */
|
|
226
|
+
export function publicAccessIssues(entries, ctx) {
|
|
227
|
+
const out = [];
|
|
228
|
+
const { index } = ctx;
|
|
229
|
+
const has = index.has;
|
|
230
|
+
for (const [name, key] of Object.entries(ctx.publicKeys ?? {})) {
|
|
231
|
+
const at = ['publicKeys', name];
|
|
232
|
+
if (key.requiresStaff === undefined) {
|
|
233
|
+
// No one signs this key in: it may only open one row by its token, and read.
|
|
234
|
+
const identity = entries.find((entry) => (entry.key ?? CUSTOMER_KEY) === name && entry.claim !== undefined);
|
|
235
|
+
if (identity?.claim === undefined || claimKind(identity.claim) !== 'token') {
|
|
236
|
+
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` });
|
|
237
|
+
}
|
|
238
|
+
entries.forEach((entry, i) => {
|
|
239
|
+
if ((entry.key ?? CUSTOMER_KEY) === name && entry.methods.some((method) => method !== 'GET')) {
|
|
240
|
+
out.push({ path: ['publicAccess', i, 'methods'], message: `"${name}" opens a row to whoever holds its link, so it only reads` });
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
if (!entries.some((entry) => entry.key === name))
|
|
244
|
+
out.push({ path: at, message: `no entry is served through "${name}"` });
|
|
245
|
+
// Nothing would read it: a shared link is switched off in its own row (the claim's `stopped`).
|
|
246
|
+
if (key.enabledBy !== undefined) {
|
|
247
|
+
out.push({ path: [...at, 'enabledBy'], message: `"${name}" opens a row by its link, which is switched off in that row, not by a setting` });
|
|
248
|
+
}
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const role = ctx.roles.find((candidate) => candidate.key === key.requiresStaff.role);
|
|
252
|
+
if (role === undefined) {
|
|
253
|
+
out.push({ path: [...at, 'requiresStaff', 'role'], message: `"${key.requiresStaff.role}" is not one of the app's roles` });
|
|
254
|
+
}
|
|
255
|
+
else if (role.screensOnly !== true || role.cloneFrom !== undefined || (role.permissions ?? []).some((grant) => grant !== 'app:@:staff')) {
|
|
256
|
+
// The screen stands where anyone can walk up to it: its sign-in may open the app's screens and nothing else.
|
|
257
|
+
out.push({
|
|
258
|
+
path: [...at, 'requiresStaff', 'role'],
|
|
259
|
+
message: `"${role.key}" signs in a screen anyone can walk up to, so it opens only the app's screens: screensOnly, and no grant but app:@:staff`,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
if (key.enabledBy !== undefined) {
|
|
263
|
+
const column = index.column(key.enabledBy.table, key.enabledBy.column);
|
|
264
|
+
if (column?.type !== 'bool') {
|
|
265
|
+
out.push({ path: [...at, 'enabledBy'], message: `"${key.enabledBy.table}.${key.enabledBy.column}" is not a bool of this app` });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (!entries.some((entry) => entry.key === name)) {
|
|
269
|
+
out.push({ path: at, message: `no entry is served through "${name}"` });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// One identity per key: a session is minted by one key, on one table.
|
|
273
|
+
const identities = new Map();
|
|
274
|
+
entries.forEach((entry, i) => {
|
|
275
|
+
if (entry.claim === undefined)
|
|
276
|
+
return;
|
|
277
|
+
const key = entry.key ?? CUSTOMER_KEY;
|
|
278
|
+
if (identities.has(key)) {
|
|
279
|
+
out.push({ path: ['publicAccess', i, 'claim'], message: `the "${key}" key already claims through entry ${String(identities.get(key).index)}; a key has one identity` });
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
identities.set(key, { entry, index: i });
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
entries.forEach((entry, i) => {
|
|
286
|
+
const at = (...rest) => ['publicAccess', i, ...rest];
|
|
287
|
+
const table = index.table(entry.table);
|
|
288
|
+
if (table === undefined) {
|
|
289
|
+
out.push({ path: at('table'), message: `"${entry.table}" is not a table of this app` });
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const column = (ref) => index.column(entry.table, ref);
|
|
293
|
+
const key = entry.key ?? CUSTOMER_KEY;
|
|
294
|
+
if (key !== CUSTOMER_KEY && ctx.publicKeys?.[key] === undefined) {
|
|
295
|
+
out.push({ path: at('key'), message: `"${key}" is not one of the app's publicKeys` });
|
|
296
|
+
}
|
|
297
|
+
for (const list of ['select', 'writable']) {
|
|
298
|
+
for (const ref of entry[list] ?? []) {
|
|
299
|
+
if (!has(entry.table, ref))
|
|
300
|
+
out.push({ path: at(list), message: `"${entry.table}" has no column "${ref}"` });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
for (const ref of [
|
|
304
|
+
...(entry.claim !== undefined && 'match' in entry.claim ? entry.claim.match : []),
|
|
305
|
+
...(entry.filters ?? []).map((f) => f.column),
|
|
306
|
+
...Object.keys(entry.defaults ?? {}),
|
|
307
|
+
]) {
|
|
308
|
+
if (!has(entry.table, ref))
|
|
309
|
+
out.push({ path: at(), message: `"${entry.table}" has no column "${ref}"` });
|
|
310
|
+
}
|
|
311
|
+
const writable = new Set(entry.writable ?? []);
|
|
312
|
+
const patches = entry.methods.includes('PATCH');
|
|
313
|
+
const creates = entry.methods.includes('POST');
|
|
314
|
+
const identity = identities.get(key);
|
|
315
|
+
// A change to an existing row, from a browser, only behind a claim: the
|
|
316
|
+
// guest reaches their own row (or a child of it) and nothing else.
|
|
317
|
+
if (patches && entry.claim === undefined && entry.visibleWith === undefined && (entry.claimedBy === undefined || entry.claimedBy.optional === true)) {
|
|
318
|
+
out.push({ path: at('methods'), message: 'PATCH is allowed only with a claim' });
|
|
319
|
+
}
|
|
320
|
+
// Nothing a server decides may be written by a browser.
|
|
321
|
+
const decided = ctx.decided(entry.table);
|
|
322
|
+
for (const ref of writable) {
|
|
323
|
+
if (decided.has(ref))
|
|
324
|
+
out.push({ path: at('writable'), message: `"${ref}" is decided by Adminium and cannot be written publicly` });
|
|
325
|
+
}
|
|
326
|
+
// A filter narrows every read AND write, so a column it names cannot also
|
|
327
|
+
// change: the row would leave the endpoint mid-write — unless both ends
|
|
328
|
+
// of the change are pinned, the state it starts from (`writableWhen`) and
|
|
329
|
+
// the values it may take (`writableValues`): a kiosk reads booked to
|
|
330
|
+
// ready and moves booked to checked in.
|
|
331
|
+
for (const filter of entry.filters ?? []) {
|
|
332
|
+
const pinned = entry.writableWhen?.[filter.column] !== undefined && entry.writableValues?.[filter.column] !== undefined;
|
|
333
|
+
if (writable.has(filter.column) && !pinned) {
|
|
334
|
+
out.push({
|
|
335
|
+
path: at('filters'),
|
|
336
|
+
message: `"${filter.column}" is filtered and writable; pin the state it changes from (writableWhen) and the values it may take (writableValues)`,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
if (filter.op === 'today' || filter.op === 'from-today') {
|
|
340
|
+
const type = column(filter.column)?.type;
|
|
341
|
+
if (type !== undefined && type !== 'date' && type !== 'timestamptz') {
|
|
342
|
+
out.push({ path: at('filters'), message: `"${filter.column}" is not a date or a timestamptz, so "${filter.op}" cannot apply` });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (entry.claim !== undefined) {
|
|
347
|
+
const claim = entry.claim;
|
|
348
|
+
if (entry.claimedBy !== undefined)
|
|
349
|
+
out.push({ path: at('claimedBy'), message: 'an identity is not also claimed by another' });
|
|
350
|
+
if (entry.visibleWith !== undefined)
|
|
351
|
+
out.push({ path: at('visibleWith'), message: 'an identity is not also visible through another' });
|
|
352
|
+
if ('email' in claim && claim.email !== undefined) {
|
|
353
|
+
const email = column(claim.email);
|
|
354
|
+
if (email === undefined)
|
|
355
|
+
out.push({ path: at('claim', 'email'), message: `"${entry.table}" has no column "${claim.email}"` });
|
|
356
|
+
else if (email.type !== 'text')
|
|
357
|
+
out.push({ path: at('claim', 'email'), message: `"${entry.table}.${email.ref}" is not a text column` });
|
|
358
|
+
}
|
|
359
|
+
const kind = claimKind(claim);
|
|
360
|
+
// Anyone can type an address: the link is only as hard to farm as its request.
|
|
361
|
+
if (kind === 'link' && entry.humanCheck !== true) {
|
|
362
|
+
out.push({ path: at('humanCheck'), message: 'a sign-in link is emailed on request, so the request asks the human check' });
|
|
363
|
+
}
|
|
364
|
+
if (kind === 'token' && 'by' in claim) {
|
|
365
|
+
const token = column(claim.column);
|
|
366
|
+
if (token === undefined)
|
|
367
|
+
out.push({ path: at('claim', 'column'), message: `"${entry.table}" has no column "${claim.column}"` });
|
|
368
|
+
else if (token.type !== 'text' || (token.rules?.code?.length ?? 0) < 16) {
|
|
369
|
+
out.push({ path: at('claim', 'column'), message: `"${entry.table}.${claim.column}" is the link's secret: a text column Adminium fills with a 16-character code` });
|
|
370
|
+
}
|
|
371
|
+
if (claim.expires !== undefined) {
|
|
372
|
+
const found = column(claim.expires);
|
|
373
|
+
if (found === undefined)
|
|
374
|
+
out.push({ path: at('claim', 'expires'), message: `"${entry.table}" has no column "${claim.expires}"` });
|
|
375
|
+
else if (found.type !== 'date' && found.type !== 'timestamptz')
|
|
376
|
+
out.push({ path: at('claim', 'expires'), message: `"${entry.table}.${claim.expires}" is not a date` });
|
|
377
|
+
}
|
|
378
|
+
if (claim.stopped !== undefined && column(claim.stopped)?.type !== 'bool') {
|
|
379
|
+
out.push({ path: at('claim', 'stopped'), message: `"${entry.table}.${claim.stopped}" is not a bool of this app` });
|
|
380
|
+
}
|
|
381
|
+
if (key === CUSTOMER_KEY || ctx.publicKeys?.[key]?.requiresStaff !== undefined) {
|
|
382
|
+
out.push({ path: at('key'), message: 'a token opens its row to whoever holds the link, on a key of its own that no staff signs in' });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (entry.claimedBy !== undefined) {
|
|
387
|
+
const by = entry.claimedBy;
|
|
388
|
+
if (identity === undefined) {
|
|
389
|
+
out.push({ path: at('claimedBy'), message: `the "${key}" key has no identity entry to be claimed by` });
|
|
390
|
+
}
|
|
391
|
+
else if (identity.entry.table !== by.table) {
|
|
392
|
+
out.push({ path: at('claimedBy', 'table'), message: `the "${key}" key claims "${identity.entry.table}", not "${by.table}"` });
|
|
393
|
+
}
|
|
394
|
+
const link = column(by.column);
|
|
395
|
+
if (link === undefined) {
|
|
396
|
+
out.push({ path: at('claimedBy', 'column'), message: `"${entry.table}" has no column "${by.column}"` });
|
|
397
|
+
}
|
|
398
|
+
else if (entry.table === by.table ? link.role !== 'pk' : link.type !== 'fk' || link.references !== by.table) {
|
|
399
|
+
out.push({
|
|
400
|
+
path: at('claimedBy', 'column'),
|
|
401
|
+
message: entry.table === by.table
|
|
402
|
+
? `"${by.column}" is not the key of "${entry.table}"`
|
|
403
|
+
: `"${entry.table}.${by.column}" does not point at "${by.table}"`,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
if (writable.has(by.column))
|
|
407
|
+
out.push({ path: at('writable'), message: `"${by.column}" is filled from the claim and cannot be written` });
|
|
408
|
+
if (by.optional === true && (entry.methods.length !== 1 || !creates)) {
|
|
409
|
+
out.push({ path: at('claimedBy', 'optional'), message: 'only a create can go through without a session' });
|
|
410
|
+
}
|
|
411
|
+
if (entry.select === undefined) {
|
|
412
|
+
out.push({ path: at('select'), message: 'a claimed entry lists what it shows; without select it would show every column' });
|
|
413
|
+
}
|
|
414
|
+
// The sensitive rule: the identity decides, each child answers.
|
|
415
|
+
if (identity?.entry.sensitive === true && entry.sensitive === undefined) {
|
|
416
|
+
out.push({ path: at('sensitive'), message: `"${identity.entry.table}" is sensitive, so this entry says whether it is too` });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
for (const name of ['level', 'onClaim', 'maxOpen']) {
|
|
421
|
+
if (name === 'level' && entry.visibleWith !== undefined)
|
|
422
|
+
continue;
|
|
423
|
+
if (entry[name] !== undefined)
|
|
424
|
+
out.push({ path: at(name), message: `${name} applies to an entry with claimedBy` });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
// A session opened by an emailed link is verified, and every entry it reads says so.
|
|
428
|
+
const root = rootIdentity(entries, i, identities);
|
|
429
|
+
if (root !== undefined && root.claim !== undefined && claimKind(root.claim) === 'link' && entry.claim === undefined && entry.level !== 'verified') {
|
|
430
|
+
out.push({ path: at('level'), message: `the "${key}" key signs people in by an emailed link, so this entry is read at level "verified"` });
|
|
431
|
+
}
|
|
432
|
+
if (entry.visibleWith !== undefined) {
|
|
433
|
+
const v = entry.visibleWith;
|
|
434
|
+
if (entry.claimedBy !== undefined)
|
|
435
|
+
out.push({ path: at('visibleWith'), message: 'an entry is claimed or visible with a parent, not both' });
|
|
436
|
+
const parents = entries.filter((other) => other !== entry && other.table === v.table && (other.key ?? CUSTOMER_KEY) === key && other.methods.includes('GET'));
|
|
437
|
+
if (parents.length === 0) {
|
|
438
|
+
out.push({ path: at('visibleWith', 'table'), message: `no entry reads "${v.table}" on the "${key}" key` });
|
|
439
|
+
}
|
|
440
|
+
else if (parents.length > 1) {
|
|
441
|
+
out.push({ path: at('visibleWith', 'table'), message: `more than one entry reads "${v.table}" on the "${key}" key, so the parent is not clear` });
|
|
442
|
+
}
|
|
443
|
+
const down = column(v.via);
|
|
444
|
+
const up = index.column(v.table, v.via);
|
|
445
|
+
const pointsUp = down?.type === 'fk' && down.references === v.table;
|
|
446
|
+
const pointsDown = up?.type === 'fk' && up.references === entry.table;
|
|
447
|
+
if (!pointsUp && !pointsDown) {
|
|
448
|
+
out.push({ path: at('visibleWith', 'via'), message: `neither "${entry.table}.${v.via}" points at "${v.table}" nor "${v.table}.${v.via}" at "${entry.table}"` });
|
|
449
|
+
}
|
|
450
|
+
/*
|
|
451
|
+
* Where the parent points at this table, the parent's column is the
|
|
452
|
+
* link its rows are read by: a browser that could write it would
|
|
453
|
+
* re-point its own row at another person's.
|
|
454
|
+
*/
|
|
455
|
+
if (pointsDown && !pointsUp) {
|
|
456
|
+
entries.forEach((other, j) => {
|
|
457
|
+
if (other.table !== v.table || (other.key ?? CUSTOMER_KEY) !== key)
|
|
458
|
+
return;
|
|
459
|
+
const writes = (other.writable ?? []).includes(v.via) ||
|
|
460
|
+
Object.prototype.hasOwnProperty.call(other.writableValues ?? {}, v.via) ||
|
|
461
|
+
Object.prototype.hasOwnProperty.call(other.defaults ?? {}, v.via);
|
|
462
|
+
if (writes) {
|
|
463
|
+
out.push({ path: ['publicAccess', j, 'writable'], message: `"${v.table}.${v.via}" is the link a child reads its rows by, so no browser writes it` });
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
if (entry.methods.includes('PATCH') && entry.writable === undefined) {
|
|
468
|
+
out.push({ path: at('writable'), message: 'a change through an entry visible with a parent names what it may write' });
|
|
469
|
+
}
|
|
470
|
+
if (hops(entries, i) > 2)
|
|
471
|
+
out.push({ path: at('visibleWith'), message: 'an entry is at most two steps from the entry its person claims' });
|
|
472
|
+
if (root === undefined)
|
|
473
|
+
out.push({ path: at('visibleWith'), message: 'the entries it is visible with lead to no claimed person' });
|
|
474
|
+
}
|
|
475
|
+
for (const ref of entry.files ?? []) {
|
|
476
|
+
if (writable.has(ref))
|
|
477
|
+
out.push({ path: at('files'), message: `"${ref}" is offered for download, so a browser never writes it` });
|
|
478
|
+
const found = column(ref);
|
|
479
|
+
if (found === undefined)
|
|
480
|
+
out.push({ path: at('files'), message: `"${entry.table}" has no column "${ref}"` });
|
|
481
|
+
else if (found.type !== 'text')
|
|
482
|
+
out.push({ path: at('files'), message: `"${entry.table}.${ref}" is not a text column holding a file` });
|
|
483
|
+
if (entry.select !== undefined && !entry.select.includes(ref))
|
|
484
|
+
out.push({ path: at('files'), message: `"${ref}" is not one of the columns the entry shows` });
|
|
485
|
+
}
|
|
486
|
+
if ((entry.files !== undefined || entry.documents !== undefined) && entry.claimedBy === undefined && entry.visibleWith === undefined && entry.claim === undefined) {
|
|
487
|
+
out.push({ path: at(entry.files !== undefined ? 'files' : 'documents'), message: 'files and documents are a signed-in person\'s own: the entry needs a claim' });
|
|
488
|
+
}
|
|
489
|
+
// A proved create opened by a claim is only as proved as the claim: the identity asks one too.
|
|
490
|
+
if (entry.humanCheck === true && entry.claimedBy !== undefined && identity !== undefined && identity.entry.humanCheck !== true) {
|
|
491
|
+
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` });
|
|
492
|
+
}
|
|
493
|
+
if (entry.level === 'verified' && identity !== undefined && identity.entry.claim !== undefined && claimKind(identity.entry.claim) !== 'link' && !('verify' in identity.entry.claim && identity.entry.claim.verify !== undefined)) {
|
|
494
|
+
out.push({ path: at('level'), message: `the "${key}" key's identity sends no code, so no session is ever verified` });
|
|
495
|
+
}
|
|
496
|
+
if (entry.sensitive === true && entry.claimedBy !== undefined && entry.level !== 'verified') {
|
|
497
|
+
out.push({ path: at('level'), message: 'a sensitive entry needs a verified session' });
|
|
498
|
+
}
|
|
499
|
+
if ((entry.sensitive === false) !== (entry.reason !== undefined)) {
|
|
500
|
+
out.push({ path: at('reason'), message: 'an entry marked not sensitive says why, and only it does' });
|
|
501
|
+
}
|
|
502
|
+
if (entry.onClaim !== undefined) {
|
|
503
|
+
if (entry.claimedBy?.optional !== true)
|
|
504
|
+
out.push({ path: at('onClaim'), message: 'onClaim applies to an entry a session is optional on' });
|
|
505
|
+
for (const ref of entry.onClaim.clear) {
|
|
506
|
+
const found = column(ref);
|
|
507
|
+
if (found === undefined)
|
|
508
|
+
out.push({ path: at('onClaim', 'clear'), message: `"${entry.table}" has no column "${ref}"` });
|
|
509
|
+
else if (found.nullable !== true)
|
|
510
|
+
out.push({ path: at('onClaim', 'clear'), message: `"${entry.table}.${ref}" is not nullable, so it cannot be emptied` });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
for (const ref of entry.requires ?? []) {
|
|
514
|
+
if (!writable.has(ref))
|
|
515
|
+
out.push({ path: at('requires'), message: `"${ref}" is not writable, so a write cannot fill it` });
|
|
516
|
+
}
|
|
517
|
+
for (const [ref, values] of Object.entries(entry.writableValues ?? {})) {
|
|
518
|
+
if (!writable.has(ref))
|
|
519
|
+
out.push({ path: at('writableValues', ref), message: `"${ref}" is not writable` });
|
|
520
|
+
const found = column(ref);
|
|
521
|
+
if (found !== undefined)
|
|
522
|
+
for (const value of values) {
|
|
523
|
+
if (!valueFits(found, value))
|
|
524
|
+
out.push({ path: at('writableValues', ref), message: `${JSON.stringify(value)} is not a value of "${entry.table}.${ref}"` });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (entry.writableWhen !== undefined && !patches) {
|
|
528
|
+
out.push({ path: at('writableWhen'), message: 'writableWhen limits a change, and this entry changes nothing' });
|
|
529
|
+
}
|
|
530
|
+
for (const [ref, when] of Object.entries(entry.writableWhen ?? {})) {
|
|
531
|
+
const found = column(ref);
|
|
532
|
+
if (found === undefined) {
|
|
533
|
+
out.push({ path: at('writableWhen', ref), message: `"${entry.table}" has no column "${ref}"` });
|
|
534
|
+
}
|
|
535
|
+
else if (when === 'from-now') {
|
|
536
|
+
if (found.type !== 'timestamptz')
|
|
537
|
+
out.push({ path: at('writableWhen', ref), message: `"from-now" needs a timestamptz, and "${ref}" is not one` });
|
|
538
|
+
}
|
|
539
|
+
else if (when === 'from-today' || when === 'before-today') {
|
|
540
|
+
if (found.type !== 'date')
|
|
541
|
+
out.push({ path: at('writableWhen', ref), message: `"${when}" needs a date, and "${ref}" is not one` });
|
|
542
|
+
}
|
|
543
|
+
else if (!Array.isArray(when)) {
|
|
544
|
+
if (found.type !== 'timestamptz')
|
|
545
|
+
out.push({ path: at('writableWhen', ref), message: `"within" needs a timestamptz, and "${ref}" is not one` });
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
for (const value of when) {
|
|
549
|
+
if (value === null) {
|
|
550
|
+
if (found.nullable !== true)
|
|
551
|
+
out.push({ path: at('writableWhen', ref), message: `"${entry.table}.${ref}" is never empty` });
|
|
552
|
+
}
|
|
553
|
+
else if (!valueFits(found, value)) {
|
|
554
|
+
out.push({ path: at('writableWhen', ref), message: `${JSON.stringify(value)} is not a value of "${entry.table}.${ref}"` });
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
// The early refusal names one time, so an entry keeps one window.
|
|
560
|
+
const windows = Object.values(entry.writableWhen ?? {}).filter((when) => typeof when === 'object' && !Array.isArray(when));
|
|
561
|
+
if (windows.length > 1) {
|
|
562
|
+
out.push({ path: at('writableWhen'), message: 'one time window per entry: a change refused as too early names one time' });
|
|
563
|
+
}
|
|
564
|
+
// A browser that may set a status must be told which statuses: a
|
|
565
|
+
// cancellation is not a licence to mark a visit seen. Held for the new
|
|
566
|
+
// claimed entries; an identity's own PATCH keeps working as it always has.
|
|
567
|
+
if (patches && entry.claimedBy !== undefined) {
|
|
568
|
+
for (const ref of writable) {
|
|
569
|
+
if (column(ref)?.type === 'enum' && entry.writableValues?.[ref] === undefined) {
|
|
570
|
+
out.push({ path: at('writableValues'), message: `"${ref}" is an enum a browser changes; list the values it may set` });
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (entry.humanCheck === true && !creates && entry.claim === undefined) {
|
|
575
|
+
out.push({ path: at('humanCheck'), message: 'a human check guards a create or a claim, and this entry is neither' });
|
|
576
|
+
}
|
|
577
|
+
if (entry.rank !== undefined) {
|
|
578
|
+
if (!creates)
|
|
579
|
+
out.push({ path: at('rank'), message: 'a rank is the answer to a create' });
|
|
580
|
+
for (const ref of [entry.rank.orderBy, ...(entry.rank.where === undefined ? [] : [entry.rank.where.column])]) {
|
|
581
|
+
if (!has(entry.table, ref))
|
|
582
|
+
out.push({ path: at('rank'), message: `"${entry.table}" has no column "${ref}"` });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (entry.maxOpen !== undefined) {
|
|
586
|
+
const max = entry.maxOpen;
|
|
587
|
+
if (!creates)
|
|
588
|
+
out.push({ path: at('maxOpen'), message: 'maxOpen limits creates' });
|
|
589
|
+
const found = column(max.column);
|
|
590
|
+
if (found === undefined)
|
|
591
|
+
out.push({ path: at('maxOpen', 'column'), message: `"${entry.table}" has no column "${max.column}"` });
|
|
592
|
+
if (found !== undefined)
|
|
593
|
+
for (const value of max.values) {
|
|
594
|
+
if (!valueFits(found, value))
|
|
595
|
+
out.push({ path: at('maxOpen', 'values'), message: `${JSON.stringify(value)} is not a value of "${entry.table}.${max.column}"` });
|
|
596
|
+
}
|
|
597
|
+
if (max.upcoming !== undefined && column(max.upcoming)?.type !== 'timestamptz') {
|
|
598
|
+
out.push({ path: at('maxOpen', 'upcoming'), message: `"${entry.table}.${max.upcoming}" is not a timestamptz` });
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (entry.anonymous !== undefined) {
|
|
602
|
+
const caps = entry.anonymous;
|
|
603
|
+
if (!creates)
|
|
604
|
+
out.push({ path: at('anonymous'), message: 'anonymous limits a create' });
|
|
605
|
+
for (const [name, columns] of [['perValue', caps.perValue?.columns ?? []], ['plainText', caps.plainText ?? []]]) {
|
|
606
|
+
for (const ref of columns) {
|
|
607
|
+
const found = column(ref);
|
|
608
|
+
if (found === undefined)
|
|
609
|
+
out.push({ path: at('anonymous', name), message: `"${entry.table}" has no column "${ref}"` });
|
|
610
|
+
else if (found.type !== 'text')
|
|
611
|
+
out.push({ path: at('anonymous', name), message: `"${entry.table}.${ref}" is not a text column` });
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
for (const [r, setting] of (entry.requireSetting ?? []).entries()) {
|
|
616
|
+
if (index.column(setting.table, setting.column)?.type !== 'bool') {
|
|
617
|
+
out.push({ path: at('requireSetting', r), message: `"${setting.table}.${setting.column}" is not a bool of this app` });
|
|
618
|
+
}
|
|
619
|
+
if (setting.when === 'anonymous' && entry.claimedBy?.optional !== true) {
|
|
620
|
+
out.push({ path: at('requireSetting', r, 'when'), message: 'only an entry a session is optional on is ever anonymous' });
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
if (entry.confirm !== undefined) {
|
|
624
|
+
const c = entry.confirm;
|
|
625
|
+
for (const [name, ref] of [['to', c.to], ['code', c.code], ['when', c.when], ['party', c.party], ['name', c.name]]) {
|
|
626
|
+
if (ref !== undefined && !has(entry.table, ref))
|
|
627
|
+
out.push({ path: at('confirm', name), message: `"${entry.table}" has no column "${ref}"` });
|
|
628
|
+
}
|
|
629
|
+
if (!creates)
|
|
630
|
+
out.push({ path: at('confirm'), message: 'a confirmation is sent on a create, and this entry creates nothing' });
|
|
631
|
+
if (c.venue !== undefined) {
|
|
632
|
+
if (index.table(c.venue.table) === undefined) {
|
|
633
|
+
out.push({ path: at('confirm', 'venue', 'table'), message: `"${c.venue.table}" is not a table of this app` });
|
|
634
|
+
}
|
|
635
|
+
else {
|
|
636
|
+
for (const [name, ref] of [['name', c.venue.name], ['address', c.venue.address], ['phone', c.venue.phone]]) {
|
|
637
|
+
if (ref !== undefined && !has(c.venue.table, ref)) {
|
|
638
|
+
out.push({ path: at('confirm', 'venue', name), message: `"${c.venue.table}" has no column "${ref}"` });
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
if (entry.kind === 'availability') {
|
|
645
|
+
if (!ctx.answersAvailability(entry.table))
|
|
646
|
+
out.push({ path: at('kind'), message: `"${entry.table}" declares no capacity or booking to answer from` });
|
|
647
|
+
if (entry.methods.some((method) => method !== 'GET'))
|
|
648
|
+
out.push({ path: at('methods'), message: 'availability is read-only' });
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
return out;
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* The identity entry an entry's person is claimed through: its own claim,
|
|
655
|
+
* its key's identity for a claimed entry, or — for one visible with a parent —
|
|
656
|
+
* whatever its parent leads to. `undefined` when the chain ends nowhere.
|
|
657
|
+
*/
|
|
658
|
+
function rootIdentity(entries, index, identities, seen = new Set()) {
|
|
659
|
+
const entry = entries[index];
|
|
660
|
+
const key = entry.key ?? CUSTOMER_KEY;
|
|
661
|
+
if (entry.claim !== undefined)
|
|
662
|
+
return entry;
|
|
663
|
+
if (entry.claimedBy !== undefined)
|
|
664
|
+
return identities.get(key)?.entry;
|
|
665
|
+
if (entry.visibleWith === undefined || seen.has(index))
|
|
666
|
+
return undefined;
|
|
667
|
+
const parent = entries.findIndex((other, i) => i !== index && other.table === entry.visibleWith.table && (other.key ?? CUSTOMER_KEY) === key && other.methods.includes('GET'));
|
|
668
|
+
return parent === -1 ? undefined : rootIdentity(entries, parent, identities, new Set([...seen, index]));
|
|
669
|
+
}
|
|
670
|
+
/** How many `visibleWith` steps an entry is from a claimed entry (a loop counts as too many). */
|
|
671
|
+
function hops(entries, index, seen = new Set()) {
|
|
672
|
+
const entry = entries[index];
|
|
673
|
+
if (entry.visibleWith === undefined)
|
|
674
|
+
return 0;
|
|
675
|
+
if (seen.has(index))
|
|
676
|
+
return Number.POSITIVE_INFINITY;
|
|
677
|
+
const key = entry.key ?? CUSTOMER_KEY;
|
|
678
|
+
const parent = entries.findIndex((other, i) => i !== index && other.table === entry.visibleWith.table && (other.key ?? CUSTOMER_KEY) === key && other.methods.includes('GET'));
|
|
679
|
+
return parent === -1 ? 1 : 1 + hops(entries, parent, new Set([...seen, index]));
|
|
680
|
+
}
|
|
681
|
+
//# sourceMappingURL=public-access.js.map
|