@adminiumjs/manifest 0.3.39 → 0.3.41

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.
@@ -31,12 +31,29 @@ import { publicConsentSchema } from './people.js';
31
31
  import { kindOf, rulesOf } from './capacity.js';
32
32
  import { formulaColumns } from './formula.js';
33
33
  import { refSchema, scalarSchema, settingRefSchema, valueFits, } from './refs.js';
34
+ import { FORMS_BECAME_TABLE_MAX } from './forms.js';
34
35
  import { momentIssues, momentOffsetSchema, plainMomentSchema, wallTimeSchema } from './refs.js';
35
36
  import { conditionIssues, linkedConditionSchema, reachedOnlyByUndo, stateConditionSchema } from './states.js';
36
37
  /** The fewest characters (its prefix apart) of a code that opens its own row (`unlockBy.self`). */
37
38
  export const SELF_UNLOCK_MIN_LENGTH = 10;
38
39
  /** The key every entry uses unless it names another. */
39
40
  export const CUSTOMER_KEY = 'customer';
41
+ /**
42
+ * The reserved key name of the entries that open a table to FORMS: an answer
43
+ * to a form may become a new row of the table, through this entry's
44
+ * `writable` columns, `defaults` and `anonymous` limits. No browser key of
45
+ * this name is ever made, and the public API never serves such an entry: it
46
+ * is read by the forms door alone, so opening a table to forms does not open
47
+ * it to the public API.
48
+ */
49
+ export const FORMS_KEY = 'forms';
50
+ /**
51
+ * What a forms entry may say. A form only ever creates a row (it never reads
52
+ * one back, changes one or signs anyone in), so every word about reading,
53
+ * claims, sessions, children, files and the entry's own confirmation mail is
54
+ * left out: forms send their own.
55
+ */
56
+ export const FORMS_ENTRY_WORDS = ['table', 'methods', 'key', 'writable', 'writableValues', 'defaults', 'requires', 'agrees', 'anonymous', 'requireSetting', 'module', 'source'];
40
57
  /** The longest a plain-text value may be unless its column says longer: a name. */
41
58
  export const PLAIN_TEXT_MAX = 80;
42
59
  /** The most digits a plain-text column may take in all: "table 12", "2 without onions", never a phone number. */
@@ -592,6 +609,13 @@ export const publicAccessSchema = z
592
609
  .optional(),
593
610
  /** Which browser key serves the entry: `customer`, or one of `publicKeys`. */
594
611
  key: keyNameSchema.optional(),
612
+ /**
613
+ * A forms entry only: the one `writable` text column a form's fixed
614
+ * "Source" value goes into (an enquiry's "From form": the form's name, or
615
+ * what the owner words it). The builder shows that row on this column;
616
+ * the answer door writes it as the form's settings say, like any other.
617
+ */
618
+ source: refSchema.optional(),
595
619
  /** The part of the app the entry belongs to: closed while it is off (see `modules.ts`). */
596
620
  module: z.string().regex(/^[a-z][a-z0-9-]{0,39}$/, 'a module id is kebab-case').optional(),
597
621
  /**
@@ -699,6 +723,9 @@ export const publicKeysSchema = z
699
723
  .record(keyNameSchema, publicKeySchema)
700
724
  .refine((keys) => !(CUSTOMER_KEY in keys) || Object.keys(keys[CUSTOMER_KEY] ?? {}).every((field) => field === 'peak'), {
701
725
  message: '"customer" is the app\'s own key: only its peak is said here',
726
+ })
727
+ .refine((keys) => !(FORMS_KEY in keys), {
728
+ message: '"forms" is the name of the entries forms write through, and no browser key: name the key otherwise',
702
729
  });
703
730
  /**
704
731
  * The own links of the rows a person of `identityTable` holds: each table a
@@ -765,6 +792,65 @@ export function resendAddressOf(outbox, kind, table, columnsOf) {
765
792
  return null;
766
793
  return { columns, ...(person === undefined ? {} : { person }) };
767
794
  }
795
+ /** Whether anyone at all may read rows through an entry: no claim, no session, no person, no parent row it is read with. */
796
+ function readByAnyone(entry) {
797
+ return (entry.methods.includes('GET') &&
798
+ entry.kind !== 'availability' &&
799
+ entry.claim === undefined &&
800
+ entry.claimedBy === undefined &&
801
+ entry.identity === undefined &&
802
+ entry.visibleWith === undefined &&
803
+ entry.level === undefined);
804
+ }
805
+ /**
806
+ * A forms entry's own rules: it creates (POST) and nothing else, under the
807
+ * anonymous limits every stranger's create has, with the columns a form may
808
+ * fill named; one per table; and no entry on any key lets anyone read the
809
+ * rows strangers add through it.
810
+ */
811
+ function formsEntryIssues(entry, i, entries, appKey, index) {
812
+ const out = [];
813
+ const at = (...rest) => ['publicAccess', i, ...rest];
814
+ // Each answer keeps the row it became by its table's stored name: it must fit an answers table, whole.
815
+ const stored = appKey === undefined ? 0 : appKey.length + 1 + entry.table.length;
816
+ if (stored > FORMS_BECAME_TABLE_MAX) {
817
+ out.push({ path: at('table'), message: `a form's answer names the row it became by "<app>:<table>", at most ${String(FORMS_BECAME_TABLE_MAX)} characters: "${appKey ?? ''}:${entry.table}" is ${String(stored)}` });
818
+ }
819
+ const allowed = FORMS_ENTRY_WORDS;
820
+ for (const word of Object.keys(entry)) {
821
+ if (!allowed.includes(word))
822
+ out.push({ path: at(word), message: `a forms entry only lets a form add a row: "${word}" is not said on it` });
823
+ }
824
+ if (entry.methods.length !== 1 || entry.methods[0] !== 'POST')
825
+ out.push({ path: at('methods'), message: 'a forms entry adds a row and does nothing else: its methods are ["POST"]' });
826
+ if (entry.anonymous === undefined)
827
+ out.push({ path: at('anonymous'), message: 'a form is sent by strangers: a forms entry says its anonymous limits' });
828
+ if ((entry.writable ?? []).length === 0)
829
+ out.push({ path: at('writable'), message: 'a forms entry names the columns a form may fill (writable)' });
830
+ if (entry.source !== undefined) {
831
+ const named = index.column(entry.table, entry.source);
832
+ if (!(entry.writable ?? []).includes(entry.source)) {
833
+ out.push({ path: at('source'), message: `a form's source goes into one of the columns a form may fill: put "${entry.source}" in writable` });
834
+ }
835
+ else if (named !== undefined && (named.type === 'fk' || named.references !== undefined)) {
836
+ out.push({ path: at('source'), message: `"${entry.source}" links to another row: a form's source is words, so it goes into a text column` });
837
+ }
838
+ else if (named !== undefined && named.type !== 'text') {
839
+ out.push({ path: at('source'), message: `"${entry.source}" holds ${named.type}: a form's source is words, so it goes into a text column` });
840
+ }
841
+ }
842
+ if (entries.findIndex((other) => other.key === FORMS_KEY && other.table === entry.table) !== i) {
843
+ out.push({ path: at('table'), message: `"${entry.table}" is opened to forms by another entry already: one forms entry per table` });
844
+ }
845
+ const reader = entries.findIndex((other) => other !== entry && other.table === entry.table && readByAnyone(other));
846
+ if (reader !== -1) {
847
+ out.push({
848
+ path: at('table'),
849
+ message: `strangers add rows to "${entry.table}" through forms, and entry ${String(reader)} lets anyone read its rows: everyone could read what people typed into a form`,
850
+ });
851
+ }
852
+ return out;
853
+ }
768
854
  /** Everything in `publicAccess` and `publicKeys` that names something undeclared, or breaks a rule. */
769
855
  export function publicAccessIssues(entries, ctx) {
770
856
  const out = [];
@@ -894,9 +980,13 @@ export function publicAccessIssues(entries, ctx) {
894
980
  }
895
981
  const column = (ref) => index.column(entry.table, ref);
896
982
  const key = entry.key ?? CUSTOMER_KEY;
897
- if (key !== CUSTOMER_KEY && ctx.publicKeys?.[key] === undefined) {
983
+ if (key !== CUSTOMER_KEY && key !== FORMS_KEY && ctx.publicKeys?.[key] === undefined) {
898
984
  out.push({ path: at('key'), message: `"${key}" is not one of the app's publicKeys` });
899
985
  }
986
+ if (key === FORMS_KEY)
987
+ out.push(...formsEntryIssues(entry, i, entries, ctx.appKey, index));
988
+ else if (entry.source !== undefined)
989
+ out.push({ path: at('source'), message: 'a source is said only on a forms entry (key "forms"): the column a form\'s fixed "Source" value goes into' });
900
990
  for (const list of ['select', 'writable']) {
901
991
  for (const ref of entry[list] ?? []) {
902
992
  if (!has(entry.table, ref))
@@ -906,8 +996,8 @@ export function publicAccessIssues(entries, ctx) {
906
996
  // An entry anyone may call (no claim, no parent, no session asked) shows no personal data: the install refuses it too.
907
997
  const sessionOnly = entry.level !== undefined && entry.claim === undefined && entry.claimedBy === undefined && entry.visibleWith === undefined;
908
998
  const anyone = entry.claim === undefined && entry.visibleWith === undefined && (entry.claimedBy === undefined || entry.claimedBy.optional === true) && !sessionOnly;
909
- // An availability entry shows no row, whatever it names.
910
- if (anyone && entry.kind !== 'availability') {
999
+ // An availability entry shows no row, whatever it names; nor does a forms entry, which answers nobody with the row.
1000
+ if (anyone && entry.kind !== 'availability' && key !== FORMS_KEY) {
911
1001
  // No select shows every column but a code Adminium makes and a secret, as the install reads it.
912
1002
  const shown = entry.select ??
913
1003
  table.columns.filter((c) => c.rules?.['code'] === undefined && c.rules?.['secret'] !== true).map((c) => c.ref);