@tabbr/module-cli 0.5.0 → 0.6.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.
package/README.md CHANGED
@@ -87,7 +87,8 @@ sandboxed, triggered on a cron schedule or on an entity's create/update - see
87
87
  "secretFieldNames": ["apiKey"],
88
88
  "triggers": [
89
89
  { "type": "CRON", "name": "pollFlights", "cronExpression": "*/5 * * * *", "handler": "pollFlights" },
90
- { "type": "ENTITY_HOOK", "name": "onFlightSaved", "entity": "flight", "event": "onUpdate", "handler": "onFlightSaved" }
90
+ { "type": "ENTITY_HOOK", "name": "onFlightSaved", "entity": "flight", "event": "onUpdate", "handler": "onFlightSaved" },
91
+ { "type": "OGN_EVENT", "name": "onOgn", "handler": "onOgnEvent" }
91
92
  ]
92
93
  }
93
94
  }
@@ -119,6 +120,21 @@ export function pollFlights() {
119
120
  `onDelete`), with that entity bound under its own name (e.g. `flight`) in the handler's scope - before the save.
120
121
  `onCreated` / `onUpdated` run after it, once committed, in the background: for a handler that reads the database
121
122
  (e.g. "is this person in that list now"). A module's own writes don't trigger its handlers.
123
+ - `"OGN_EVENT"` (module API 1.10, `"engines": { "tabbr": "^1.10" }`) runs for every start and landing OGN reports that
124
+ the club gets: on one of its airfields, or by one of the airplanes it follows wherever they fly - the club sets both
125
+ in Instellingen › Vliegtuigen › OGN. The handler gets an `OgnEvent` (`@tabbr/module-sdk/backend`):
126
+ `{ type: 'TAKEOFF' | 'LANDING', fieldCode, address, time, atClubField, followedAirplane, airplaneId }`. One handler
127
+ runs at a time, in the order the events came; keep it quick (OGN can report many in a busy hour), and make getting
128
+ the same event twice harmless. The club sees "krijgt de starts en landingen van OGN" when
129
+ it installs the module.
130
+
131
+ ```ts
132
+ export function onOgnEvent(event: OgnEvent) {
133
+ if (event.type === 'LANDING' && event.airplaneId) {
134
+ // e.g. close the flight of that airplane that is still in the air
135
+ }
136
+ }
137
+ ```
122
138
  - Needs `esbuild` installed in the workspace (`npm install -D esbuild`) - only used at publish
123
139
  time, bundled into one dependency-free `bundle.js` the server runs in a GraalVM sandbox, so
124
140
  regular npm packages can be imported in your TS but nothing Node-specific (no `fs`, no native
@@ -85,9 +85,12 @@ Backend part (all in ${CONFIG_FILE} under "backend", not as CLI flags):
85
85
  secretFieldNames Names of your module's settings keys that hold secrets (masked from
86
86
  every settings GET response once saved)
87
87
  triggers [{type: "CRON", name, cronExpression, handler} |
88
- {type: "ENTITY_HOOK", name, entity, event, handler} |
88
+ {type: "ENTITY_HOOK", name, entity, event, handler, serializeOn?} |
89
89
  {type: "PUBLIC", name, handler, params} (module API 1.5:
90
- GET /api/<club>/public/module/<moduleId>/data/<name>, no login)]
90
+ GET /api/<club>/public/module/<moduleId>/data/<name>, no login) |
91
+ {type: "OGN_EVENT", name, handler} (module API 1.10: the club's OGN starts/landings)]
92
+ publicActions [{name, handler, rateLimit?: "10/h", verifiedEmail?: true}] - functions anyone may
93
+ call without logging in: POST /api/<club>/public/custommodule/<id>/action/<name>
91
94
 
92
95
  Optional:
93
96
  --icon <name> Menu icon name
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tabbr/module-cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Set up, build and publish a Native Federation club module to a tabbr club.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "SystemWorks BV",
@@ -46,6 +46,41 @@ function entitiesOf(config) {
46
46
  return entities;
47
47
  }
48
48
 
49
+ /**
50
+ * The audiences the module declares (`"audiences"` in tabbr.config.json, module API 1.8): `{ key, name, description,
51
+ * criteria }` - made among the club's audiences when the module is installed. Checked just enough to fail here: keys,
52
+ * names, and every `{audience:<key>}` in the entities' rules declared. Tabbr checks them again (criteria about the
53
+ * logged-in user, audiences in a circle).
54
+ */
55
+ function audiencesOf(config, entities) {
56
+ const audiences = config.audiences || [];
57
+ if (!Array.isArray(audiences)) {
58
+ throw new Error('"audiences" in tabbr.config.json must be an array: [{ "key": "uploaders", "name": "Fotografen", "criteria": [...] }].');
59
+ }
60
+ const keys = new Set();
61
+ for (const audience of audiences) {
62
+ if (!audience || !/^[a-z][a-zA-Z0-9]{0,39}$/.test(audience.key || '')) {
63
+ throw new Error(`Audience key "${audience && audience.key}" is not valid: camelCase letters and digits, starting lowercase.`);
64
+ }
65
+ if (keys.has(audience.key)) {
66
+ throw new Error(`Audience "${audience.key}" is declared twice.`);
67
+ }
68
+ keys.add(audience.key);
69
+ if (typeof audience.name !== 'string' || !audience.name.trim() || audience.name.length > 80) {
70
+ throw new Error(`Audience "${audience.key}" needs a name (at most 80 characters).`);
71
+ }
72
+ if (audience.criteria != null && !Array.isArray(audience.criteria)) {
73
+ throw new Error(`Audience "${audience.key}": "criteria" must be an array of lines ({ "andFilterValues": [...] }).`);
74
+ }
75
+ }
76
+ for (const match of JSON.stringify(entities).matchAll(/\{audience:([^}]*)\}/g)) {
77
+ if (!keys.has(match[1])) {
78
+ throw new Error(`A rule refers to {audience:${match[1]}}, which "audiences" in tabbr.config.json doesn't declare.`);
79
+ }
80
+ }
81
+ return audiences;
82
+ }
83
+
49
84
  const CONTRACT_NAME = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
50
85
 
51
86
  /**
@@ -118,18 +153,19 @@ function providesOf(cwd, config) {
118
153
  * and the contracts it uses and provides - which a module can do without any backend code, so then only the manifest
119
154
  * is produced (`bundleJs` null). `files`: more files for the build's `backend/` (the TypeScript of what it provides).
120
155
  *
121
- * Returns `null` when the workspace has no backend part, no entities and uses or provides no contracts, so `build.js`
156
+ * Returns `null` when the workspace has no backend part, no entities, no audiences and uses or provides no contracts, so `build.js`
122
157
  * can skip it entirely for such a frontend-only module.
123
158
  */
124
159
  async function buildBackend(cwd, config) {
125
160
  const backendConfig = config.backend;
126
161
  const entities = entitiesOf(config);
162
+ const audiences = audiencesOf(config, entities);
127
163
  const uses = usesOf(config);
128
164
  const optionalUses = usesOf(config, 'optionalUses');
129
165
  const provides = providesOf(cwd, config);
130
166
  const bundleJs = backendConfig ? await bundle(cwd, backendConfig) : null;
131
- if (bundleJs == null && entities.length === 0 && Object.keys(uses).length === 0 && Object.keys(optionalUses).length === 0
132
- && Object.keys(provides.versions).length === 0) {
167
+ if (bundleJs == null && entities.length === 0 && audiences.length === 0 && Object.keys(uses).length === 0
168
+ && Object.keys(optionalUses).length === 0 && Object.keys(provides.versions).length === 0) {
133
169
  return null;
134
170
  }
135
171
  const settings = backendConfig || {};
@@ -138,19 +174,34 @@ async function buildBackend(cwd, config) {
138
174
  allowedHosts: [...(settings.allowedHosts || []), ...(config.local ? settings.localAllowedHosts || [] : [])],
139
175
  secretFieldNames: settings.secretFieldNames || [],
140
176
  triggers: bundleJs == null ? [] : settings.triggers || [],
177
+ // functions anyone may call without logging in (e.g. submitting a public form) - shown to the club when it installs
178
+ publicActions: bundleJs == null ? [] : settings.publicActions || [],
141
179
  entities,
142
180
  // the module gives persons roles (tabbr.access.setManagedRoles) - shown to the club when it installs the module
143
181
  manageRoles: bundleJs != null && settings.manageRoles === true,
182
+ // the built-in entities (besides its own) it may change - permissions.entities.write; the server refuses any other write
183
+ writeEntities: bundleJs != null ? writeEntitiesOf(config) : [],
144
184
  // the contracts it uses - for its frontend part as well, so the manifest is written for a frontend-only module too
145
185
  uses,
146
186
  optionalUses,
147
187
  // the contracts it provides, with their version; their TypeScript goes along as backend/contracts/<name>.d.ts
148
188
  provides: provides.versions,
189
+ // its own audiences (module API 1.8), made among the club's when it is installed
190
+ audiences,
149
191
  };
150
192
 
151
193
  return { bundleJs, manifestJson: JSON.stringify(manifest, null, 2), files: provides.files };
152
194
  }
153
195
 
196
+ /** `permissions.entities.write`: names of entities the module may change besides its own (e.g. "person"). */
197
+ function writeEntitiesOf(config) {
198
+ const write = config.permissions?.entities?.write ?? [];
199
+ if (!Array.isArray(write) || write.some((name) => typeof name !== 'string' || !/^[a-z][a-zA-Z0-9]{1,39}$/.test(name))) {
200
+ throw new Error('"permissions.entities.write" must be a list of entity names, e.g. ["person"].');
201
+ }
202
+ return [...new Set(write)];
203
+ }
204
+
154
205
  /** The backend entry bundled into one script, or null when there is no entry file (and none was configured explicitly). */
155
206
  async function bundle(cwd, backendConfig) {
156
207
  const entry = backendConfig.entry || DEFAULT_ENTRY;
@@ -191,4 +242,4 @@ async function bundle(cwd, backendConfig) {
191
242
  return result.outputFiles[0].text;
192
243
  }
193
244
 
194
- module.exports = { buildBackend, entitiesOf, usesOf, providesOf, DEFAULT_ENTRY };
245
+ module.exports = { buildBackend, entitiesOf, audiencesOf, usesOf, providesOf, DEFAULT_ENTRY };