@adminiumjs/manifest 0.3.42 → 0.3.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/automations.js +1 -1
- package/dist/automations.js.map +1 -1
- package/dist/change-guards.js +1 -1
- package/dist/change-guards.js.map +1 -1
- package/dist/check.d.ts +81 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +130 -0
- package/dist/check.js.map +1 -0
- package/dist/expire.d.ts +46 -0
- package/dist/expire.d.ts.map +1 -0
- package/dist/expire.js +59 -0
- package/dist/expire.js.map +1 -0
- package/dist/formula.d.ts +6 -1
- package/dist/formula.d.ts.map +1 -1
- package/dist/formula.js +34 -6
- package/dist/formula.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/people.d.ts +45 -4
- package/dist/people.d.ts.map +1 -1
- package/dist/people.js +74 -8
- package/dist/people.js.map +1 -1
- package/dist/public-access.d.ts +14 -0
- package/dist/public-access.d.ts.map +1 -1
- package/dist/public-access.js +183 -6
- package/dist/public-access.js.map +1 -1
- package/dist/roles.d.ts +4 -0
- package/dist/roles.d.ts.map +1 -1
- package/dist/roles.js +5 -0
- package/dist/roles.js.map +1 -1
- package/dist/schema.d.ts +808 -5
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +456 -20
- package/dist/schema.js.map +1 -1
- package/dist/validate.js +1 -1
- package/dist/validate.js.map +1 -1
- package/dist/words.d.ts +3 -1
- package/dist/words.d.ts.map +1 -1
- package/dist/words.js +130 -0
- package/dist/words.js.map +1 -1
- package/package.json +2 -2
package/dist/public-access.js
CHANGED
|
@@ -390,6 +390,8 @@ const childEntryShape = {
|
|
|
390
390
|
plainText: plainTextSchema.optional(),
|
|
391
391
|
/** The most a column may add up to across the rows of one write (a dozen items to an order). */
|
|
392
392
|
sumMax: sumMaxSchema.optional(),
|
|
393
|
+
/** File columns of the child a guest may upload into (`POST /public/uploads`), as on the entry itself. */
|
|
394
|
+
uploads: z.array(refSchema).min(1).max(8).optional(),
|
|
393
395
|
};
|
|
394
396
|
const childTablesSchema = (entry) => z.record(refSchema, entry).refine((children) => {
|
|
395
397
|
const count = Object.keys(children).length;
|
|
@@ -462,7 +464,8 @@ export const publicAccessSchema = z
|
|
|
462
464
|
.object({
|
|
463
465
|
table: refSchema,
|
|
464
466
|
kind: z.enum(['records', 'availability']).optional(),
|
|
465
|
-
|
|
467
|
+
/** `DELETE` only on an add-on's own link: its holder removes their own row while it is in a state the entry names. */
|
|
468
|
+
methods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE'])).min(1),
|
|
466
469
|
select: z.array(refSchema).optional(),
|
|
467
470
|
writable: z.array(refSchema).optional(),
|
|
468
471
|
filters: z.array(z.union([valueFilterSchema, relativeFilterSchema])).optional(),
|
|
@@ -494,6 +497,14 @@ export const publicAccessSchema = z
|
|
|
494
497
|
* unticked writes nothing: it takes back no earlier yes.
|
|
495
498
|
*/
|
|
496
499
|
consent: publicConsentSchema.optional(),
|
|
500
|
+
/**
|
|
501
|
+
* On an add-on's entry, which rides the key of every app it is attached
|
|
502
|
+
* to: a text column of the table that holds the key of the app a row is
|
|
503
|
+
* for (`app`). Through an app's key the entry reads only the rows whose
|
|
504
|
+
* column holds that app's key, a create writes that key there (the
|
|
505
|
+
* browser cannot choose it), and a change never moves it.
|
|
506
|
+
*/
|
|
507
|
+
appColumn: refSchema.optional(),
|
|
497
508
|
/** On a signed-in person's rows: "Make a new link" for a row's own link. */
|
|
498
509
|
newLink: newLinkSchema.optional(),
|
|
499
510
|
/**
|
|
@@ -504,6 +515,14 @@ export const publicAccessSchema = z
|
|
|
504
515
|
visibleWith: z.object({ table: refSchema, via: refSchema }).strict().optional(),
|
|
505
516
|
/** File columns a claimed person may download (the file the row names, never by its id). */
|
|
506
517
|
files: z.array(refSchema).min(1).max(8).optional(),
|
|
518
|
+
/**
|
|
519
|
+
* File columns a guest may upload into (`POST /public/uploads`): each a
|
|
520
|
+
* `writable` file column whose rule says what a guest may send
|
|
521
|
+
* (`rules.file.upload`). The upload is kept for a day; the guest's next
|
|
522
|
+
* create or change through this entry names it in the column, and it
|
|
523
|
+
* becomes the row's own file.
|
|
524
|
+
*/
|
|
525
|
+
uploads: z.array(refSchema).min(1).max(8).optional(),
|
|
507
526
|
/** The kinds of document a claimed person may list and open for these rows. */
|
|
508
527
|
documents: z.array(z.string().regex(/^[a-z][a-z0-9-]*$/, 'a document kind')).min(1).max(8).optional(),
|
|
509
528
|
/**
|
|
@@ -685,6 +704,13 @@ export const publicAccessSchema = z
|
|
|
685
704
|
.optional(),
|
|
686
705
|
/** Image columns any visitor may see, through the rows this entry reads. */
|
|
687
706
|
pictures: z.array(refSchema).min(1).max(4).optional(),
|
|
707
|
+
/**
|
|
708
|
+
* File columns of product assets every visitor's browser may load,
|
|
709
|
+
* through the rows this entry reads (`GET /public/assets`): a preview
|
|
710
|
+
* model (GLB), a print model (STL, 3MF), a web font (WOFF2), an SVG piece.
|
|
711
|
+
* Each column holds files by a `file` rule that takes only those kinds.
|
|
712
|
+
*/
|
|
713
|
+
assets: z.array(refSchema).min(1).max(8).optional(),
|
|
688
714
|
/**
|
|
689
715
|
* On rows reached through a parent: `columns` are left out unless
|
|
690
716
|
* `unlessHolder` is empty or names the session's own person (a ticket
|
|
@@ -851,11 +877,40 @@ function formsEntryIssues(entry, i, entries, appKey, index) {
|
|
|
851
877
|
}
|
|
852
878
|
return out;
|
|
853
879
|
}
|
|
880
|
+
/**
|
|
881
|
+
* An entry held to the app whose key serves it (`appColumn`): a text column
|
|
882
|
+
* of its table, never one a browser writes or a value the entry sets, and
|
|
883
|
+
* only on an entry that rides each app's own key (no key of the add-on's,
|
|
884
|
+
* no sign-in of its own).
|
|
885
|
+
*/
|
|
886
|
+
function appColumnIssues(entries, index) {
|
|
887
|
+
const out = [];
|
|
888
|
+
entries.forEach((entry, i) => {
|
|
889
|
+
const column = entry.appColumn;
|
|
890
|
+
if (column === undefined)
|
|
891
|
+
return;
|
|
892
|
+
const at = ['publicAccess', i, 'appColumn'];
|
|
893
|
+
const found = index.column(entry.table, column);
|
|
894
|
+
if (found === undefined)
|
|
895
|
+
out.push({ path: at, message: `"${entry.table}" has no column "${column}"` });
|
|
896
|
+
else if (found.type !== 'text')
|
|
897
|
+
out.push({ path: at, message: `"${entry.table}.${column}" is not a text column, so it cannot hold an app's key` });
|
|
898
|
+
if ((entry.writable ?? []).includes(column) || Object.prototype.hasOwnProperty.call(entry.defaults ?? {}, column)) {
|
|
899
|
+
out.push({ path: at, message: `"${column}" holds the app whose key serves the entry: Adminium writes it, so it is neither writable nor a default` });
|
|
900
|
+
}
|
|
901
|
+
if ((entry.key ?? CUSTOMER_KEY) !== CUSTOMER_KEY)
|
|
902
|
+
out.push({ path: at, message: `an entry on "${String(entry.key)}" is served by no app's key, so it has no app to be held to` });
|
|
903
|
+
if (entry.claim !== undefined || entry.identity !== undefined)
|
|
904
|
+
out.push({ path: at, message: 'a sign-in entry is the key\'s own, and is held to no app' });
|
|
905
|
+
});
|
|
906
|
+
return out;
|
|
907
|
+
}
|
|
854
908
|
/** Everything in `publicAccess` and `publicKeys` that names something undeclared, or breaks a rule. */
|
|
855
909
|
export function publicAccessIssues(entries, ctx) {
|
|
856
910
|
const out = [];
|
|
857
911
|
const { index } = ctx;
|
|
858
912
|
const has = index.has;
|
|
913
|
+
out.push(...appColumnIssues(entries, index));
|
|
859
914
|
for (const [name, key] of Object.entries(ctx.publicKeys ?? {})) {
|
|
860
915
|
const at = ['publicKeys', name];
|
|
861
916
|
// The app's own key, named only for its peak: nothing more to check.
|
|
@@ -1030,14 +1085,25 @@ export function publicAccessIssues(entries, ctx) {
|
|
|
1030
1085
|
if (patches && entry.claim === undefined && entry.visibleWith === undefined && (entry.claimedBy === undefined || entry.claimedBy.optional === true)) {
|
|
1031
1086
|
out.push({ path: at('methods'), message: 'PATCH is allowed only with a claim' });
|
|
1032
1087
|
}
|
|
1088
|
+
/*
|
|
1089
|
+
* A removal from a browser: only through an add-on's own link (a shopper
|
|
1090
|
+
* deleting their own saved draft), on the entry that opens the row by it.
|
|
1091
|
+
* Nothing else a guest holds removes a row.
|
|
1092
|
+
*/
|
|
1093
|
+
if (entry.methods.includes('DELETE')) {
|
|
1094
|
+
const ownLink = ctx.ridesAppKeys === true && entry.key !== undefined && entry.claim !== undefined && 'by' in entry.claim && entry.claim.own === true;
|
|
1095
|
+
if (!ownLink)
|
|
1096
|
+
out.push({ path: at('methods'), message: "DELETE is allowed only on an add-on's own link, the entry that opens the row by it (claim by token, own)" });
|
|
1097
|
+
}
|
|
1033
1098
|
// Nothing a server decides may be written by a browser.
|
|
1034
1099
|
const decided = ctx.decided(entry.table);
|
|
1035
1100
|
for (const ref of writable) {
|
|
1036
1101
|
if (decided.has(ref))
|
|
1037
1102
|
out.push({ path: at('writable'), message: `"${ref}" is decided by Adminium and cannot be written publicly` });
|
|
1038
1103
|
}
|
|
1039
|
-
// A file column names a file a signed-in staff member uploaded: a browser's key uploads nothing, so it names none
|
|
1040
|
-
|
|
1104
|
+
// A file column names a file a signed-in staff member uploaded: a browser's key uploads nothing, so it names none —
|
|
1105
|
+
// but a guest's own upload, in a column the entry lets them upload into (`uploads`).
|
|
1106
|
+
for (const [list, refs] of [['writable', [...writable].filter((ref) => !(entry.uploads ?? []).includes(ref))], ['defaults', Object.keys(entry.defaults ?? {})]]) {
|
|
1041
1107
|
for (const ref of refs) {
|
|
1042
1108
|
const found = ctx.index.column(entry.table, ref);
|
|
1043
1109
|
if (found?.rules?.file !== undefined)
|
|
@@ -1220,7 +1286,8 @@ export function publicAccessIssues(entries, ctx) {
|
|
|
1220
1286
|
out.push({ path: at('visibleWith'), message: 'the entries it is visible with lead to no claimed person' });
|
|
1221
1287
|
}
|
|
1222
1288
|
for (const ref of entry.files ?? []) {
|
|
1223
|
-
|
|
1289
|
+
// A guest's own upload is written by naming it, and only a file they uploaded for the column is taken there.
|
|
1290
|
+
if (writable.has(ref) && !(entry.uploads ?? []).includes(ref))
|
|
1224
1291
|
out.push({ path: at('files'), message: `"${ref}" is offered for download, so a browser never writes it` });
|
|
1225
1292
|
const found = column(ref);
|
|
1226
1293
|
if (found === undefined)
|
|
@@ -1325,7 +1392,7 @@ export function publicAccessIssues(entries, ctx) {
|
|
|
1325
1392
|
!('within' in when) &&
|
|
1326
1393
|
[when.after, when.before].some((end) => end !== undefined) &&
|
|
1327
1394
|
[when.after, when.before].every((end) => end === undefined || end.column !== undefined));
|
|
1328
|
-
if (entry.writableWhen !== undefined && !patches && !createWindow) {
|
|
1395
|
+
if (entry.writableWhen !== undefined && !patches && !entry.methods.includes('DELETE') && !createWindow) {
|
|
1329
1396
|
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)" });
|
|
1330
1397
|
}
|
|
1331
1398
|
for (const [ref, when] of Object.entries(entry.writableWhen ?? {})) {
|
|
@@ -1505,6 +1572,13 @@ export function publicAccessIssues(entries, ctx) {
|
|
|
1505
1572
|
out.push(...unlockIssues(entry, entry.unlockBy, entries, ctx, at));
|
|
1506
1573
|
if (entry.pictures !== undefined)
|
|
1507
1574
|
out.push(...pictureIssues(entry, entry.pictures, entries, ctx, at));
|
|
1575
|
+
if (entry.uploads !== undefined) {
|
|
1576
|
+
if (!entry.methods.includes('POST') && !entry.methods.includes('PATCH'))
|
|
1577
|
+
out.push({ path: at('uploads'), message: "a guest's upload is named by a create or a change, and this entry makes neither" });
|
|
1578
|
+
out.push(...uploadIssues(ctx.index, entry.table, entry.uploads, entry.writable ?? [], at('uploads')));
|
|
1579
|
+
}
|
|
1580
|
+
if (entry.assets !== undefined)
|
|
1581
|
+
out.push(...assetIssues(entry, entry.assets, entries, ctx, at));
|
|
1508
1582
|
});
|
|
1509
1583
|
out.push(...treeIssues(entries, ctx, identities, shareCodes));
|
|
1510
1584
|
out.push(...personIssues(entries, ctx, identities));
|
|
@@ -1686,6 +1760,105 @@ function pictureIssues(entry, pictures, entries, ctx, at) {
|
|
|
1686
1760
|
}
|
|
1687
1761
|
return out;
|
|
1688
1762
|
}
|
|
1763
|
+
/** The kinds of file a product asset may be (`ASSET_FILE_KINDS` in `schema.ts`, which imports this file). */
|
|
1764
|
+
const ASSET_KINDS = ['svg', 'glb', 'stl', '3mf', 'woff2'];
|
|
1765
|
+
/**
|
|
1766
|
+
* Product assets every visitor's browser loads: file columns the entry shows,
|
|
1767
|
+
* held to a model's, a font's or an SVG's kinds, which no browser writes. The
|
|
1768
|
+
* same shape as pictures: a viewer `fetch`es a model and `@font-face` loads a
|
|
1769
|
+
* font with no session, so the rows are read by every visitor alike.
|
|
1770
|
+
*/
|
|
1771
|
+
function assetIssues(entry, assets, entries, ctx, at) {
|
|
1772
|
+
const out = [];
|
|
1773
|
+
if (entry.methods.length !== 1 || entry.methods[0] !== 'GET' || entry.kind === 'availability') {
|
|
1774
|
+
out.push({ path: at('assets'), message: 'assets are served through an entry that only reads rows' });
|
|
1775
|
+
}
|
|
1776
|
+
if (entry.claim !== undefined || entry.claimedBy !== undefined || entry.visibleWith !== undefined) {
|
|
1777
|
+
out.push({ path: at('assets'), message: "assets are for every visitor; a signed-in person's own files are `files`" });
|
|
1778
|
+
}
|
|
1779
|
+
if (entry.unlockBy !== undefined)
|
|
1780
|
+
out.push({ path: at('assets'), message: 'assets are for every visitor; rows a code unlocks are read only by whoever gave the code' });
|
|
1781
|
+
if (entry.level !== undefined && entry.claim === undefined && entry.claimedBy === undefined && entry.visibleWith === undefined) {
|
|
1782
|
+
out.push({ path: at('assets'), message: 'assets are for every visitor; rows read by a signed-in session alone are no asset a page loads for anyone' });
|
|
1783
|
+
}
|
|
1784
|
+
if ((entry.key ?? CUSTOMER_KEY) !== CUSTOMER_KEY)
|
|
1785
|
+
out.push({ path: at('key'), message: "assets are served through the app's customer key" });
|
|
1786
|
+
const writes = new Set([...(entry.writable ?? []), ...Object.keys(entry.defaults ?? {})]);
|
|
1787
|
+
const shareCodes = shareCodeColumns(entries, entry.table, ctx.index);
|
|
1788
|
+
const guests = guestUploadColumns(entries, entry.table);
|
|
1789
|
+
if (new Set(assets).size !== assets.length)
|
|
1790
|
+
out.push({ path: at('assets'), message: 'an asset column is named twice' });
|
|
1791
|
+
for (const ref of assets) {
|
|
1792
|
+
const found = ctx.index.column(entry.table, ref);
|
|
1793
|
+
if (found === undefined) {
|
|
1794
|
+
out.push({ path: at('assets'), message: `"${entry.table}" has no column "${ref}"` });
|
|
1795
|
+
continue;
|
|
1796
|
+
}
|
|
1797
|
+
const accept = found.rules?.file?.accept;
|
|
1798
|
+
if (accept === undefined || accept.length === 0 || accept.some((kind) => !ASSET_KINDS.includes(kind))) {
|
|
1799
|
+
out.push({ path: at('assets'), message: `"${entry.table}.${ref}" is not a column of assets: a file rule that takes only ${ASSET_KINDS.join(', ')}` });
|
|
1800
|
+
}
|
|
1801
|
+
if (writes.has(ref))
|
|
1802
|
+
out.push({ path: at('assets'), message: `"${ref}" is an asset every visitor loads, so a browser never writes it` });
|
|
1803
|
+
if (entry.select !== undefined && !entry.select.includes(ref))
|
|
1804
|
+
out.push({ path: at('assets'), message: `"${ref}" is not one of the columns the entry shows` });
|
|
1805
|
+
if ((entry.files ?? []).includes(ref))
|
|
1806
|
+
out.push({ path: at('assets'), message: `"${ref}" is a signed-in person's file, not an asset for everyone` });
|
|
1807
|
+
if ((entry.pictures ?? []).includes(ref))
|
|
1808
|
+
out.push({ path: at('assets'), message: `"${ref}" is a picture already; a column is one or the other` });
|
|
1809
|
+
if (found.rules?.secret === true || found.rules?.code !== undefined || shareCodes.includes(ref)) {
|
|
1810
|
+
out.push({ path: at('assets'), message: `"${entry.table}.${ref}" is kept from readers, so it is no asset for everyone` });
|
|
1811
|
+
}
|
|
1812
|
+
if (found.rules?.personal === true)
|
|
1813
|
+
out.push({ path: at('assets'), message: `"${entry.table}.${ref}" is personal data, so it is no asset for everyone` });
|
|
1814
|
+
if (guests.has(ref))
|
|
1815
|
+
out.push({ path: at('assets'), message: `guests upload into "${entry.table}.${ref}", and a guest's file is kept private, so it is no asset for everyone` });
|
|
1816
|
+
}
|
|
1817
|
+
return out;
|
|
1818
|
+
}
|
|
1819
|
+
/** The columns of `table` any entry lets a guest upload into, on the entry itself or on a level of its child rows. */
|
|
1820
|
+
function guestUploadColumns(entries, table) {
|
|
1821
|
+
const out = new Set();
|
|
1822
|
+
const walk = (children) => {
|
|
1823
|
+
for (const [name, child] of Object.entries(children ?? {})) {
|
|
1824
|
+
if (name === table)
|
|
1825
|
+
for (const ref of child.uploads ?? [])
|
|
1826
|
+
out.add(ref);
|
|
1827
|
+
walk(child.children);
|
|
1828
|
+
}
|
|
1829
|
+
};
|
|
1830
|
+
for (const entry of entries) {
|
|
1831
|
+
if (entry.table === table)
|
|
1832
|
+
for (const ref of entry.uploads ?? [])
|
|
1833
|
+
out.add(ref);
|
|
1834
|
+
walk(entry.children);
|
|
1835
|
+
}
|
|
1836
|
+
return out;
|
|
1837
|
+
}
|
|
1838
|
+
/**
|
|
1839
|
+
* The file columns a guest may upload into, at one level of an entry: each a
|
|
1840
|
+
* column of the table, written there, whose file rule says what a guest may
|
|
1841
|
+
* send (`rules.file.upload`). Named once each.
|
|
1842
|
+
*/
|
|
1843
|
+
function uploadIssues(index, table, uploads, writable, path) {
|
|
1844
|
+
const out = [];
|
|
1845
|
+
if (new Set(uploads).size !== uploads.length)
|
|
1846
|
+
out.push({ path, message: 'each column is named once' });
|
|
1847
|
+
for (const ref of uploads) {
|
|
1848
|
+
const found = ruledColumn(index, table, ref);
|
|
1849
|
+
if (found === undefined) {
|
|
1850
|
+
out.push({ path, message: `"${table}" has no column "${ref}"` });
|
|
1851
|
+
continue;
|
|
1852
|
+
}
|
|
1853
|
+
if (found.rules?.file === undefined)
|
|
1854
|
+
out.push({ path, message: `"${table}.${ref}" holds no files: give it a file rule (rules.file)` });
|
|
1855
|
+
else if (found.rules.file.upload === undefined)
|
|
1856
|
+
out.push({ path, message: `"${table}.${ref}" does not say what a guest may upload: give its file rule an upload (kinds, maxBytes)` });
|
|
1857
|
+
if (!writable.includes(ref))
|
|
1858
|
+
out.push({ path, message: `"${ref}" is filled by naming the upload, so it is writable` });
|
|
1859
|
+
}
|
|
1860
|
+
return out;
|
|
1861
|
+
}
|
|
1689
1862
|
/**
|
|
1690
1863
|
* Whether an entry reads a table's rows: a GET that is not an availability
|
|
1691
1864
|
* answer (free or full per slot, never a row), so it can be a child's parent.
|
|
@@ -2093,6 +2266,8 @@ function treeIssues(entries, ctx, identities, shareCodes) {
|
|
|
2093
2266
|
}
|
|
2094
2267
|
}
|
|
2095
2268
|
out.push(...boundIssues(index, ctx, name, child.writable, here('writable')));
|
|
2269
|
+
if (child.uploads !== undefined)
|
|
2270
|
+
out.push(...uploadIssues(index, name, child.uploads, child.writable, here('uploads')));
|
|
2096
2271
|
(child.agrees ?? []).forEach((agree, a) => out.push(...agreeIssues(index, name, parent, agree, (...rest) => here('agrees', a, ...rest))));
|
|
2097
2272
|
(child.counts ?? []).forEach((counts, c) => out.push(...countsIssues(index, name, parent, counts, (...rest) => here('counts', c, ...rest))));
|
|
2098
2273
|
if (child.children !== undefined) {
|
|
@@ -2429,7 +2604,9 @@ function personIssues(entries, ctx, identities) {
|
|
|
2429
2604
|
if (entry.level !== undefined && entry.claim === undefined && entry.claimedBy === undefined && entry.visibleWith === undefined) {
|
|
2430
2605
|
if (entry.methods.length !== 1 || entry.methods[0] !== 'GET')
|
|
2431
2606
|
out.push({ path: at('methods'), message: 'a read for a signed-in guest alone only reads' });
|
|
2432
|
-
|
|
2607
|
+
// An add-on's entry on an app's key is read with a session the app's own sign-in opened (a guest who found their order).
|
|
2608
|
+
const appSignsIn = ctx.ridesAppKeys === true && key === CUSTOMER_KEY;
|
|
2609
|
+
if (!identities.has(key) && !appSignsIn)
|
|
2433
2610
|
out.push({ path: at('level'), message: `the "${key}" key signs nobody in, so no session could read this` });
|
|
2434
2611
|
if (entry.select === undefined)
|
|
2435
2612
|
out.push({ path: at('select'), message: 'a read for a signed-in guest lists what it shows; without select it would show every column' });
|