@adminiumjs/manifest 0.3.0-rc.4 → 0.3.0

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.
@@ -0,0 +1,167 @@
1
+ /**
2
+ * `publicAccess` — what the app's public screens may do with one table, and
3
+ * `publicKeys`, the browser keys beyond the one every app gets.
4
+ *
5
+ * An entry is served through a browser key the install creates: `customer`
6
+ * (the default, the app's public side) or one named in `publicKeys`. `GET`
7
+ * reads, `POST` creates; `PATCH` only behind a claim, on a narrow writable
8
+ * list. `availability` answers free or full per slot and never a row.
9
+ *
10
+ * CLAIMS. An entry with `claim` is the key's IDENTITY: proving you know a
11
+ * row's details (a mobile and a date of birth) opens a session on that row.
12
+ * An entry with `claimedBy` is a child of that identity: it reaches only the
13
+ * rows whose column holds the claimed row's key. `verify: 'email-code'` adds a
14
+ * second step, a code emailed to the row's own address, which raises the
15
+ * session from `lookup` to `verified`; an entry that says `level: 'verified'`
16
+ * refuses a session that has not taken it.
17
+ */
18
+ import { z } from 'zod';
19
+ import { type ReferenceIssue, type TableIndex } from './refs.js';
20
+ /** The key every entry uses unless it names another. */
21
+ export declare const CUSTOMER_KEY = "customer";
22
+ export declare const publicAccessSchema: z.ZodObject<{
23
+ table: z.ZodString;
24
+ kind: z.ZodOptional<z.ZodEnum<{
25
+ records: "records";
26
+ availability: "availability";
27
+ }>>;
28
+ methods: z.ZodArray<z.ZodEnum<{
29
+ GET: "GET";
30
+ POST: "POST";
31
+ PATCH: "PATCH";
32
+ }>>;
33
+ select: z.ZodOptional<z.ZodArray<z.ZodString>>;
34
+ writable: z.ZodOptional<z.ZodArray<z.ZodString>>;
35
+ filters: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
36
+ column: z.ZodString;
37
+ op: z.ZodEnum<{
38
+ in: "in";
39
+ eq: "eq";
40
+ neq: "neq";
41
+ gte: "gte";
42
+ lte: "lte";
43
+ }>;
44
+ value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>]>;
45
+ }, z.core.$strict>, z.ZodUnion<readonly [z.ZodObject<{
46
+ column: z.ZodString;
47
+ op: z.ZodLiteral<"today">;
48
+ }, z.core.$strict>, z.ZodObject<{
49
+ column: z.ZodString;
50
+ op: z.ZodLiteral<"from-today">;
51
+ days: z.ZodOptional<z.ZodNumber>;
52
+ }, z.core.$strict>]>]>>>;
53
+ defaults: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>;
54
+ claim: z.ZodOptional<z.ZodObject<{
55
+ match: z.ZodArray<z.ZodString>;
56
+ verify: z.ZodOptional<z.ZodLiteral<"email-code">>;
57
+ email: z.ZodOptional<z.ZodString>;
58
+ }, z.core.$strict>>;
59
+ claimedBy: z.ZodOptional<z.ZodObject<{
60
+ table: z.ZodString;
61
+ column: z.ZodString;
62
+ optional: z.ZodOptional<z.ZodLiteral<true>>;
63
+ }, z.core.$strict>>;
64
+ level: z.ZodOptional<z.ZodEnum<{
65
+ lookup: "lookup";
66
+ verified: "verified";
67
+ }>>;
68
+ sensitive: z.ZodOptional<z.ZodBoolean>;
69
+ reason: z.ZodOptional<z.ZodString>;
70
+ onClaim: z.ZodOptional<z.ZodObject<{
71
+ clear: z.ZodArray<z.ZodString>;
72
+ }, z.core.$strict>>;
73
+ writableValues: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>>;
74
+ writableWhen: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>, z.ZodLiteral<"from-now">, z.ZodObject<{
75
+ within: z.ZodNumber;
76
+ }, z.core.$strict>]>>>;
77
+ humanCheck: z.ZodOptional<z.ZodLiteral<true>>;
78
+ rank: z.ZodOptional<z.ZodObject<{
79
+ orderBy: z.ZodString;
80
+ where: z.ZodOptional<z.ZodObject<{
81
+ column: z.ZodString;
82
+ eq: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>;
83
+ }, z.core.$strict>>;
84
+ }, z.core.$strict>>;
85
+ maxOpen: z.ZodOptional<z.ZodObject<{
86
+ column: z.ZodString;
87
+ values: z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>;
88
+ n: z.ZodNumber;
89
+ upcoming: z.ZodOptional<z.ZodString>;
90
+ }, z.core.$strict>>;
91
+ anonymous: z.ZodOptional<z.ZodObject<{
92
+ perValue: z.ZodOptional<z.ZodObject<{
93
+ columns: z.ZodArray<z.ZodString>;
94
+ n: z.ZodNumber;
95
+ }, z.core.$strict>>;
96
+ perKeyHour: z.ZodOptional<z.ZodNumber>;
97
+ plainText: z.ZodOptional<z.ZodArray<z.ZodString>>;
98
+ }, z.core.$strict>>;
99
+ requireSetting: z.ZodOptional<z.ZodArray<z.ZodObject<{
100
+ table: z.ZodString;
101
+ column: z.ZodString;
102
+ when: z.ZodOptional<z.ZodLiteral<"anonymous">>;
103
+ }, z.core.$strict>>>;
104
+ key: z.ZodOptional<z.ZodString>;
105
+ confirm: z.ZodOptional<z.ZodObject<{
106
+ template: z.ZodEnum<{
107
+ "booking-confirmation": "booking-confirmation";
108
+ }>;
109
+ to: z.ZodString;
110
+ code: z.ZodOptional<z.ZodString>;
111
+ when: z.ZodOptional<z.ZodString>;
112
+ party: z.ZodOptional<z.ZodString>;
113
+ name: z.ZodOptional<z.ZodString>;
114
+ venue: z.ZodOptional<z.ZodObject<{
115
+ table: z.ZodString;
116
+ name: z.ZodOptional<z.ZodString>;
117
+ address: z.ZodOptional<z.ZodString>;
118
+ phone: z.ZodOptional<z.ZodString>;
119
+ }, z.core.$strict>>;
120
+ link: z.ZodOptional<z.ZodString>;
121
+ }, z.core.$strict>>;
122
+ }, z.core.$strict>;
123
+ export type PublicAccess = z.infer<typeof publicAccessSchema>;
124
+ /**
125
+ * A browser key besides `customer`. It is never published to the app's public
126
+ * side: the staff side hands it only to someone signed in with `role`, and
127
+ * every request on it must carry that person's staff session too — a token
128
+ * copied out of the page opens nothing on its own. `enabledBy` switches it
129
+ * off with a bool in the settings row.
130
+ */
131
+ export declare const publicKeySchema: z.ZodObject<{
132
+ requiresStaff: z.ZodObject<{
133
+ role: z.ZodString;
134
+ }, z.core.$strict>;
135
+ enabledBy: z.ZodOptional<z.ZodObject<{
136
+ table: z.ZodString;
137
+ column: z.ZodString;
138
+ }, z.core.$strict>>;
139
+ }, z.core.$strict>;
140
+ export declare const publicKeysSchema: z.ZodRecord<z.ZodString, z.ZodObject<{
141
+ requiresStaff: z.ZodObject<{
142
+ role: z.ZodString;
143
+ }, z.core.$strict>;
144
+ enabledBy: z.ZodOptional<z.ZodObject<{
145
+ table: z.ZodString;
146
+ column: z.ZodString;
147
+ }, z.core.$strict>>;
148
+ }, z.core.$strict>>;
149
+ export type PublicKey = z.infer<typeof publicKeySchema>;
150
+ interface PublicAccessContext {
151
+ index: TableIndex;
152
+ /** Columns Adminium decides on a table: a browser may never write them. */
153
+ decided: (table: string) => ReadonlySet<string>;
154
+ /** Whether the table carries a capacity or a booking rule to answer availability from. */
155
+ answersAvailability: (table: string) => boolean;
156
+ publicKeys: Readonly<Record<string, PublicKey>> | undefined;
157
+ roles: readonly {
158
+ key: string;
159
+ screensOnly?: boolean | undefined;
160
+ cloneFrom?: string | undefined;
161
+ permissions?: readonly string[] | undefined;
162
+ }[];
163
+ }
164
+ /** Everything in `publicAccess` and `publicKeys` that names something undeclared, or breaks a rule. */
165
+ export declare function publicAccessIssues(entries: readonly PublicAccess[], ctx: PublicAccessContext): ReferenceIssue[];
166
+ export {};
167
+ //# sourceMappingURL=public-access.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"public-access.d.ts","sourceRoot":"","sources":["../src/public-access.ts"],"names":[],"mappings":"AACA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAKL,KAAK,cAAc,EACnB,KAAK,UAAU,EAChB,MAAM,WAAW,CAAC;AAEnB,wDAAwD;AACxD,eAAO,MAAM,YAAY,aAAa,CAAC;AA+BvC,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBA4GpB,CAAC;AACZ,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAE9D;;;;;;GAMG;AACH,eAAO,MAAM,eAAe;;;;;;;;kBAKjB,CAAC;AACZ,eAAO,MAAM,gBAAgB;;;;;;;;mBAEyF,CAAC;AACvH,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAExD,UAAU,mBAAmB;IAC3B,KAAK,EAAE,UAAU,CAAC;IAClB,2EAA2E;IAC3E,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,WAAW,CAAC,MAAM,CAAC,CAAC;IAChD,0FAA0F;IAC1F,mBAAmB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;IAChD,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC,GAAG,SAAS,CAAC;IAC5D,KAAK,EAAE,SAAS;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;QAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAAA;KAAE,EAAE,CAAC;CACnJ;AAED,uGAAuG;AACvG,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,SAAS,YAAY,EAAE,EAAE,GAAG,EAAE,mBAAmB,GAAG,cAAc,EAAE,CAmR/G"}
@@ -0,0 +1,475 @@
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
+ import { z } from 'zod';
20
+ import { refSchema, scalarSchema, settingRefSchema, valueFits, } from './refs.js';
21
+ /** The key every entry uses unless it names another. */
22
+ export const CUSTOMER_KEY = 'customer';
23
+ const keyNameSchema = z.string().regex(/^[a-z][a-z0-9-]{0,31}$/, 'a key name is kebab-case');
24
+ const valueFilterSchema = z
25
+ .object({
26
+ column: refSchema,
27
+ op: z.enum(['eq', 'neq', 'in', 'gte', 'lte']),
28
+ value: z.union([z.string(), z.number(), z.boolean(), z.array(z.union([z.string(), z.number()]))]),
29
+ })
30
+ .strict();
31
+ /**
32
+ * A filter against the venue's own calendar, worked out on every request in
33
+ * the venue's zone: `today` is today's date, `from-today` today onwards
34
+ * (`days` limits how far).
35
+ */
36
+ const relativeFilterSchema = z.union([
37
+ z.object({ column: refSchema, op: z.literal('today') }).strict(),
38
+ z.object({ column: refSchema, op: z.literal('from-today'), days: z.number().int().min(1).max(366).optional() }).strict(),
39
+ ]);
40
+ const valuesSchema = z.array(scalarSchema).min(1).max(32);
41
+ /**
42
+ * A time no more than `within` minutes ahead — a past time always passes. A
43
+ * kiosk takes an arrival from an hour before the visit, and a late one too.
44
+ * Up to a day: a longer window is no window.
45
+ */
46
+ const timeWindowSchema = z.object({ within: z.number().int().min(1).max(1440) }).strict();
47
+ export const publicAccessSchema = z
48
+ .object({
49
+ table: refSchema,
50
+ kind: z.enum(['records', 'availability']).optional(),
51
+ methods: z.array(z.enum(['GET', 'POST', 'PATCH'])).min(1),
52
+ select: z.array(refSchema).optional(),
53
+ writable: z.array(refSchema).optional(),
54
+ filters: z.array(z.union([valueFilterSchema, relativeFilterSchema])).optional(),
55
+ /** Values the server writes, whatever the browser sends (`status: confirmed`). */
56
+ defaults: z.record(refSchema, z.union([z.string(), z.number(), z.boolean()])).optional(),
57
+ /**
58
+ * Proving you know a row's details, e.g. `{match: [code, mobile]}`. With
59
+ * `verify`, a code sent to the row's `email` column raises the session to
60
+ * `verified`.
61
+ */
62
+ claim: z
63
+ .object({
64
+ match: z.array(refSchema).min(1).max(3),
65
+ verify: z.literal('email-code').optional(),
66
+ email: refSchema.optional(),
67
+ })
68
+ .strict()
69
+ .refine((c) => (c.verify === undefined) === (c.email === undefined), {
70
+ message: 'an emailed code names the column holding the address, and only it does',
71
+ path: ['email'],
72
+ })
73
+ .optional(),
74
+ /**
75
+ * The rows of a claimed person: `column` holds the key of the row the key's
76
+ * identity entry on `table` claims. `optional` on a create lets it go
77
+ * through with no session at all (a first visit, by someone not yet on file).
78
+ */
79
+ claimedBy: z.object({ table: refSchema, column: refSchema, optional: z.literal(true).optional() }).strict().optional(),
80
+ /** The session this entry needs: `verified` once the emailed code is confirmed. */
81
+ level: z.enum(['lookup', 'verified']).optional(),
82
+ /**
83
+ * Rows a person must prove more to see. An identity marked sensitive shows
84
+ * only its own `select` on a lookup; each entry it claims must then say
85
+ * whether it is sensitive itself — and, when it is not, `reason` why not.
86
+ */
87
+ sensitive: z.boolean().optional(),
88
+ reason: z.string().min(1).max(200).optional(),
89
+ /** On an optional claim, the columns a session's create empties (the details of a first visit). */
90
+ onClaim: z.object({ clear: z.array(refSchema).min(1).max(12) }).strict().optional(),
91
+ /** The only values a browser may write into these columns. */
92
+ writableValues: z.record(refSchema, valuesSchema).optional(),
93
+ /**
94
+ * The state a row must be IN to be changed — part of the update itself,
95
+ * never of a read, so a finished visit still lists but cannot be moved.
96
+ * `from-now` on a time: only while it is still ahead. `{within: 60}` on a
97
+ * time: no more than 60 minutes ahead, and a change made earlier is
98
+ * refused with that time (`PUBLIC_TOO_EARLY`), even when `select` leaves
99
+ * it out — naming the window is agreeing to that.
100
+ */
101
+ writableWhen: z.record(refSchema, z.union([valuesSchema, z.literal('from-now'), timeWindowSchema])).optional(),
102
+ /** A proof-of-work the browser solves before the write (or the claim) is taken. */
103
+ humanCheck: z.literal(true).optional(),
104
+ /** A create answers where the new row stands: the rows ordered at or before it. */
105
+ rank: z
106
+ .object({ orderBy: refSchema, where: z.object({ column: refSchema, eq: scalarSchema }).strict().optional() })
107
+ .strict()
108
+ .optional(),
109
+ /** A claimed person may hold at most `n` rows whose column is one of `values` (and, with `upcoming`, still ahead). */
110
+ maxOpen: z
111
+ .object({ column: refSchema, values: valuesSchema, n: z.number().int().min(1).max(50), upcoming: refSchema.optional() })
112
+ .strict()
113
+ .optional(),
114
+ /**
115
+ * The limits on a create nobody signed in for: per phone number or
116
+ * address a day, per key an hour, and columns that hold plain text only.
117
+ */
118
+ anonymous: z
119
+ .object({
120
+ perValue: z.object({ columns: z.array(refSchema).min(1).max(4), n: z.number().int().min(1).max(20) }).strict().optional(),
121
+ perKeyHour: z.number().int().min(1).max(1000).optional(),
122
+ plainText: z.array(refSchema).min(1).max(8).optional(),
123
+ })
124
+ .strict()
125
+ .optional(),
126
+ /** Refused while a bool in the settings row is false (only for a session-less create with `anonymous`). */
127
+ requireSetting: z
128
+ .array(settingRefSchema.extend({ when: z.literal('anonymous').optional() }).strict())
129
+ .max(4)
130
+ .optional(),
131
+ /** Which browser key serves the entry: `customer`, or one of `publicKeys`. */
132
+ key: keyNameSchema.optional(),
133
+ /**
134
+ * The confirmation Adminium emails when a guest creates a row here: the
135
+ * column holding their address, the columns the email shows, the app's
136
+ * one-row venue table, and the path under the guest side that manages it.
137
+ */
138
+ confirm: z
139
+ .object({
140
+ template: z.enum(['booking-confirmation']),
141
+ to: refSchema,
142
+ code: refSchema.optional(),
143
+ when: refSchema.optional(),
144
+ party: refSchema.optional(),
145
+ name: refSchema.optional(),
146
+ venue: z
147
+ .object({ table: refSchema, name: refSchema.optional(), address: refSchema.optional(), phone: refSchema.optional() })
148
+ .strict()
149
+ .optional(),
150
+ link: z.string().max(200).optional(),
151
+ })
152
+ .strict()
153
+ .optional(),
154
+ })
155
+ .strict();
156
+ /**
157
+ * A browser key besides `customer`. It is never published to the app's public
158
+ * side: the staff side hands it only to someone signed in with `role`, and
159
+ * every request on it must carry that person's staff session too — a token
160
+ * copied out of the page opens nothing on its own. `enabledBy` switches it
161
+ * off with a bool in the settings row.
162
+ */
163
+ export const publicKeySchema = z
164
+ .object({
165
+ requiresStaff: z.object({ role: z.string().regex(/^[a-z][a-z0-9-]*$/, 'a role key') }).strict(),
166
+ enabledBy: settingRefSchema.optional(),
167
+ })
168
+ .strict();
169
+ export const publicKeysSchema = z
170
+ .record(keyNameSchema, publicKeySchema)
171
+ .refine((keys) => !(CUSTOMER_KEY in keys), { message: '"customer" is the app\'s own key and is not declared here' });
172
+ /** Everything in `publicAccess` and `publicKeys` that names something undeclared, or breaks a rule. */
173
+ export function publicAccessIssues(entries, ctx) {
174
+ const out = [];
175
+ const { index } = ctx;
176
+ const has = index.has;
177
+ for (const [name, key] of Object.entries(ctx.publicKeys ?? {})) {
178
+ const at = ['publicKeys', name];
179
+ const role = ctx.roles.find((candidate) => candidate.key === key.requiresStaff.role);
180
+ if (role === undefined) {
181
+ out.push({ path: [...at, 'requiresStaff', 'role'], message: `"${key.requiresStaff.role}" is not one of the app's roles` });
182
+ }
183
+ else if (role.screensOnly !== true || role.cloneFrom !== undefined || (role.permissions ?? []).some((grant) => grant !== 'app:@:staff')) {
184
+ // The screen stands where anyone can walk up to it: its sign-in may open the app's screens and nothing else.
185
+ out.push({
186
+ path: [...at, 'requiresStaff', 'role'],
187
+ 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`,
188
+ });
189
+ }
190
+ if (key.enabledBy !== undefined) {
191
+ const column = index.column(key.enabledBy.table, key.enabledBy.column);
192
+ if (column?.type !== 'bool') {
193
+ out.push({ path: [...at, 'enabledBy'], message: `"${key.enabledBy.table}.${key.enabledBy.column}" is not a bool of this app` });
194
+ }
195
+ }
196
+ if (!entries.some((entry) => entry.key === name)) {
197
+ out.push({ path: at, message: `no entry is served through "${name}"` });
198
+ }
199
+ }
200
+ // One identity per key: a session is minted by one key, on one table.
201
+ const identities = new Map();
202
+ entries.forEach((entry, i) => {
203
+ if (entry.claim === undefined)
204
+ return;
205
+ const key = entry.key ?? CUSTOMER_KEY;
206
+ if (identities.has(key)) {
207
+ out.push({ path: ['publicAccess', i, 'claim'], message: `the "${key}" key already claims through entry ${String(identities.get(key).index)}; a key has one identity` });
208
+ }
209
+ else {
210
+ identities.set(key, { entry, index: i });
211
+ }
212
+ });
213
+ entries.forEach((entry, i) => {
214
+ const at = (...rest) => ['publicAccess', i, ...rest];
215
+ const table = index.table(entry.table);
216
+ if (table === undefined) {
217
+ out.push({ path: at('table'), message: `"${entry.table}" is not a table of this app` });
218
+ return;
219
+ }
220
+ const column = (ref) => index.column(entry.table, ref);
221
+ const key = entry.key ?? CUSTOMER_KEY;
222
+ if (key !== CUSTOMER_KEY && ctx.publicKeys?.[key] === undefined) {
223
+ out.push({ path: at('key'), message: `"${key}" is not one of the app's publicKeys` });
224
+ }
225
+ for (const list of ['select', 'writable']) {
226
+ for (const ref of entry[list] ?? []) {
227
+ if (!has(entry.table, ref))
228
+ out.push({ path: at(list), message: `"${entry.table}" has no column "${ref}"` });
229
+ }
230
+ }
231
+ for (const ref of [
232
+ ...(entry.claim?.match ?? []),
233
+ ...(entry.filters ?? []).map((f) => f.column),
234
+ ...Object.keys(entry.defaults ?? {}),
235
+ ]) {
236
+ if (!has(entry.table, ref))
237
+ out.push({ path: at(), message: `"${entry.table}" has no column "${ref}"` });
238
+ }
239
+ const writable = new Set(entry.writable ?? []);
240
+ const patches = entry.methods.includes('PATCH');
241
+ const creates = entry.methods.includes('POST');
242
+ const identity = identities.get(key);
243
+ // A change to an existing row, from a browser, only behind a claim: the
244
+ // guest reaches their own row and nothing else.
245
+ if (patches && entry.claim === undefined && (entry.claimedBy === undefined || entry.claimedBy.optional === true)) {
246
+ out.push({ path: at('methods'), message: 'PATCH is allowed only with a claim' });
247
+ }
248
+ // Nothing a server decides may be written by a browser.
249
+ const decided = ctx.decided(entry.table);
250
+ for (const ref of writable) {
251
+ if (decided.has(ref))
252
+ out.push({ path: at('writable'), message: `"${ref}" is decided by Adminium and cannot be written publicly` });
253
+ }
254
+ // A filter narrows every read AND write, so a column it names cannot also
255
+ // change: the row would leave the endpoint mid-write — unless both ends
256
+ // of the change are pinned, the state it starts from (`writableWhen`) and
257
+ // the values it may take (`writableValues`): a kiosk reads booked to
258
+ // ready and moves booked to checked in.
259
+ for (const filter of entry.filters ?? []) {
260
+ const pinned = entry.writableWhen?.[filter.column] !== undefined && entry.writableValues?.[filter.column] !== undefined;
261
+ if (writable.has(filter.column) && !pinned) {
262
+ out.push({
263
+ path: at('filters'),
264
+ message: `"${filter.column}" is filtered and writable; pin the state it changes from (writableWhen) and the values it may take (writableValues)`,
265
+ });
266
+ }
267
+ if (filter.op === 'today' || filter.op === 'from-today') {
268
+ const type = column(filter.column)?.type;
269
+ if (type !== undefined && type !== 'date' && type !== 'timestamptz') {
270
+ out.push({ path: at('filters'), message: `"${filter.column}" is not a date or a timestamptz, so "${filter.op}" cannot apply` });
271
+ }
272
+ }
273
+ }
274
+ if (entry.claim !== undefined) {
275
+ if (entry.claimedBy !== undefined)
276
+ out.push({ path: at('claimedBy'), message: 'an identity is not also claimed by another' });
277
+ if (entry.claim.email !== undefined) {
278
+ const email = column(entry.claim.email);
279
+ if (email === undefined)
280
+ out.push({ path: at('claim', 'email'), message: `"${entry.table}" has no column "${entry.claim.email}"` });
281
+ else if (email.type !== 'text')
282
+ out.push({ path: at('claim', 'email'), message: `"${entry.table}.${email.ref}" is not a text column` });
283
+ }
284
+ }
285
+ if (entry.claimedBy !== undefined) {
286
+ const by = entry.claimedBy;
287
+ if (identity === undefined) {
288
+ out.push({ path: at('claimedBy'), message: `the "${key}" key has no identity entry to be claimed by` });
289
+ }
290
+ else if (identity.entry.table !== by.table) {
291
+ out.push({ path: at('claimedBy', 'table'), message: `the "${key}" key claims "${identity.entry.table}", not "${by.table}"` });
292
+ }
293
+ const link = column(by.column);
294
+ if (link === undefined) {
295
+ out.push({ path: at('claimedBy', 'column'), message: `"${entry.table}" has no column "${by.column}"` });
296
+ }
297
+ else if (entry.table === by.table ? link.role !== 'pk' : link.type !== 'fk' || link.references !== by.table) {
298
+ out.push({
299
+ path: at('claimedBy', 'column'),
300
+ message: entry.table === by.table
301
+ ? `"${by.column}" is not the key of "${entry.table}"`
302
+ : `"${entry.table}.${by.column}" does not point at "${by.table}"`,
303
+ });
304
+ }
305
+ if (writable.has(by.column))
306
+ out.push({ path: at('writable'), message: `"${by.column}" is filled from the claim and cannot be written` });
307
+ if (by.optional === true && (entry.methods.length !== 1 || !creates)) {
308
+ out.push({ path: at('claimedBy', 'optional'), message: 'only a create can go through without a session' });
309
+ }
310
+ if (entry.select === undefined) {
311
+ out.push({ path: at('select'), message: 'a claimed entry lists what it shows; without select it would show every column' });
312
+ }
313
+ // The sensitive rule: the identity decides, each child answers.
314
+ if (identity?.entry.sensitive === true && entry.sensitive === undefined) {
315
+ out.push({ path: at('sensitive'), message: `"${identity.entry.table}" is sensitive, so this entry says whether it is too` });
316
+ }
317
+ }
318
+ else {
319
+ for (const name of ['level', 'onClaim', 'maxOpen']) {
320
+ if (entry[name] !== undefined)
321
+ out.push({ path: at(name), message: `${name} applies to an entry with claimedBy` });
322
+ }
323
+ }
324
+ // A proved create opened by a claim is only as proved as the claim: the identity asks one too.
325
+ if (entry.humanCheck === true && entry.claimedBy !== undefined && identity !== undefined && identity.entry.humanCheck !== true) {
326
+ 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` });
327
+ }
328
+ if (entry.level === 'verified' && identity !== undefined && identity.entry.claim?.verify === undefined) {
329
+ out.push({ path: at('level'), message: `the "${key}" key's identity sends no code, so no session is ever verified` });
330
+ }
331
+ if (entry.sensitive === true && entry.claimedBy !== undefined && entry.level !== 'verified') {
332
+ out.push({ path: at('level'), message: 'a sensitive entry needs a verified session' });
333
+ }
334
+ if ((entry.sensitive === false) !== (entry.reason !== undefined)) {
335
+ out.push({ path: at('reason'), message: 'an entry marked not sensitive says why, and only it does' });
336
+ }
337
+ if (entry.onClaim !== undefined) {
338
+ if (entry.claimedBy?.optional !== true)
339
+ out.push({ path: at('onClaim'), message: 'onClaim applies to an entry a session is optional on' });
340
+ for (const ref of entry.onClaim.clear) {
341
+ const found = column(ref);
342
+ if (found === undefined)
343
+ out.push({ path: at('onClaim', 'clear'), message: `"${entry.table}" has no column "${ref}"` });
344
+ else if (found.nullable !== true)
345
+ out.push({ path: at('onClaim', 'clear'), message: `"${entry.table}.${ref}" is not nullable, so it cannot be emptied` });
346
+ }
347
+ }
348
+ for (const [ref, values] of Object.entries(entry.writableValues ?? {})) {
349
+ if (!writable.has(ref))
350
+ out.push({ path: at('writableValues', ref), message: `"${ref}" is not writable` });
351
+ const found = column(ref);
352
+ if (found !== undefined)
353
+ for (const value of values) {
354
+ if (!valueFits(found, value))
355
+ out.push({ path: at('writableValues', ref), message: `${JSON.stringify(value)} is not a value of "${entry.table}.${ref}"` });
356
+ }
357
+ }
358
+ if (entry.writableWhen !== undefined && !patches) {
359
+ out.push({ path: at('writableWhen'), message: 'writableWhen limits a change, and this entry changes nothing' });
360
+ }
361
+ for (const [ref, when] of Object.entries(entry.writableWhen ?? {})) {
362
+ const found = column(ref);
363
+ if (found === undefined) {
364
+ out.push({ path: at('writableWhen', ref), message: `"${entry.table}" has no column "${ref}"` });
365
+ }
366
+ else if (when === 'from-now') {
367
+ if (found.type !== 'timestamptz')
368
+ out.push({ path: at('writableWhen', ref), message: `"from-now" needs a timestamptz, and "${ref}" is not one` });
369
+ }
370
+ else if (!Array.isArray(when)) {
371
+ if (found.type !== 'timestamptz')
372
+ out.push({ path: at('writableWhen', ref), message: `"within" needs a timestamptz, and "${ref}" is not one` });
373
+ }
374
+ else {
375
+ for (const value of when) {
376
+ if (!valueFits(found, value))
377
+ out.push({ path: at('writableWhen', ref), message: `${JSON.stringify(value)} is not a value of "${entry.table}.${ref}"` });
378
+ }
379
+ }
380
+ }
381
+ // The early refusal names one time, so an entry keeps one window.
382
+ const windows = Object.values(entry.writableWhen ?? {}).filter((when) => typeof when === 'object' && !Array.isArray(when));
383
+ if (windows.length > 1) {
384
+ out.push({ path: at('writableWhen'), message: 'one time window per entry: a change refused as too early names one time' });
385
+ }
386
+ // A browser that may set a status must be told which statuses: a
387
+ // cancellation is not a licence to mark a visit seen. Held for the new
388
+ // claimed entries; an identity's own PATCH keeps working as it always has.
389
+ if (patches && entry.claimedBy !== undefined) {
390
+ for (const ref of writable) {
391
+ if (column(ref)?.type === 'enum' && entry.writableValues?.[ref] === undefined) {
392
+ out.push({ path: at('writableValues'), message: `"${ref}" is an enum a browser changes; list the values it may set` });
393
+ }
394
+ }
395
+ }
396
+ if (entry.humanCheck === true && !creates && entry.claim === undefined) {
397
+ out.push({ path: at('humanCheck'), message: 'a human check guards a create or a claim, and this entry is neither' });
398
+ }
399
+ if (entry.rank !== undefined) {
400
+ if (!creates)
401
+ out.push({ path: at('rank'), message: 'a rank is the answer to a create' });
402
+ for (const ref of [entry.rank.orderBy, ...(entry.rank.where === undefined ? [] : [entry.rank.where.column])]) {
403
+ if (!has(entry.table, ref))
404
+ out.push({ path: at('rank'), message: `"${entry.table}" has no column "${ref}"` });
405
+ }
406
+ }
407
+ if (entry.maxOpen !== undefined) {
408
+ const max = entry.maxOpen;
409
+ if (!creates)
410
+ out.push({ path: at('maxOpen'), message: 'maxOpen limits creates' });
411
+ const found = column(max.column);
412
+ if (found === undefined)
413
+ out.push({ path: at('maxOpen', 'column'), message: `"${entry.table}" has no column "${max.column}"` });
414
+ if (found !== undefined)
415
+ for (const value of max.values) {
416
+ if (!valueFits(found, value))
417
+ out.push({ path: at('maxOpen', 'values'), message: `${JSON.stringify(value)} is not a value of "${entry.table}.${max.column}"` });
418
+ }
419
+ if (max.upcoming !== undefined && column(max.upcoming)?.type !== 'timestamptz') {
420
+ out.push({ path: at('maxOpen', 'upcoming'), message: `"${entry.table}.${max.upcoming}" is not a timestamptz` });
421
+ }
422
+ }
423
+ if (entry.anonymous !== undefined) {
424
+ const caps = entry.anonymous;
425
+ if (!creates)
426
+ out.push({ path: at('anonymous'), message: 'anonymous limits a create' });
427
+ for (const [name, columns] of [['perValue', caps.perValue?.columns ?? []], ['plainText', caps.plainText ?? []]]) {
428
+ for (const ref of columns) {
429
+ const found = column(ref);
430
+ if (found === undefined)
431
+ out.push({ path: at('anonymous', name), message: `"${entry.table}" has no column "${ref}"` });
432
+ else if (found.type !== 'text')
433
+ out.push({ path: at('anonymous', name), message: `"${entry.table}.${ref}" is not a text column` });
434
+ }
435
+ }
436
+ }
437
+ for (const [r, setting] of (entry.requireSetting ?? []).entries()) {
438
+ if (index.column(setting.table, setting.column)?.type !== 'bool') {
439
+ out.push({ path: at('requireSetting', r), message: `"${setting.table}.${setting.column}" is not a bool of this app` });
440
+ }
441
+ if (setting.when === 'anonymous' && entry.claimedBy?.optional !== true) {
442
+ out.push({ path: at('requireSetting', r, 'when'), message: 'only an entry a session is optional on is ever anonymous' });
443
+ }
444
+ }
445
+ if (entry.confirm !== undefined) {
446
+ const c = entry.confirm;
447
+ for (const [name, ref] of [['to', c.to], ['code', c.code], ['when', c.when], ['party', c.party], ['name', c.name]]) {
448
+ if (ref !== undefined && !has(entry.table, ref))
449
+ out.push({ path: at('confirm', name), message: `"${entry.table}" has no column "${ref}"` });
450
+ }
451
+ if (!creates)
452
+ out.push({ path: at('confirm'), message: 'a confirmation is sent on a create, and this entry creates nothing' });
453
+ if (c.venue !== undefined) {
454
+ if (index.table(c.venue.table) === undefined) {
455
+ out.push({ path: at('confirm', 'venue', 'table'), message: `"${c.venue.table}" is not a table of this app` });
456
+ }
457
+ else {
458
+ for (const [name, ref] of [['name', c.venue.name], ['address', c.venue.address], ['phone', c.venue.phone]]) {
459
+ if (ref !== undefined && !has(c.venue.table, ref)) {
460
+ out.push({ path: at('confirm', 'venue', name), message: `"${c.venue.table}" has no column "${ref}"` });
461
+ }
462
+ }
463
+ }
464
+ }
465
+ }
466
+ if (entry.kind === 'availability') {
467
+ if (!ctx.answersAvailability(entry.table))
468
+ out.push({ path: at('kind'), message: `"${entry.table}" declares no capacity or booking to answer from` });
469
+ if (entry.methods.some((method) => method !== 'GET'))
470
+ out.push({ path: at('methods'), message: 'availability is read-only' });
471
+ }
472
+ });
473
+ return out;
474
+ }
475
+ //# sourceMappingURL=public-access.js.map