@xeplr/cli 0.3.3 → 0.3.5

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.
Files changed (37) hide show
  1. package/README.md +33 -3
  2. package/lib/generate/index.js +58 -1
  3. package/lib/plan.js +1 -0
  4. package/lib/questions.js +21 -2
  5. package/package.json +1 -1
  6. package/templates/CLAUDE.md +9 -7
  7. package/templates/README.md +14 -7
  8. package/templates/api/README.md +12 -4
  9. package/templates/api/app.js +2 -0
  10. package/templates/api/bin/www +13 -5
  11. package/templates/api/db/setup.js +1 -1
  12. package/templates/api/development.env.tpl +26 -12
  13. package/templates/api/env.required.js +7 -1
  14. package/templates/api/functions/index.js +47 -0
  15. package/templates/api/migrations-auth/0005_flows_menu.sql +14 -0
  16. package/templates/api/package.json +7 -4
  17. package/templates/api/production.env.example +6 -4
  18. package/templates/api/routes/flows.js +23 -0
  19. package/templates/api/routes/index.js +4 -0
  20. package/templates/api/screens/index.js +4 -6
  21. package/templates/api/screens/task/task-edit.screen.json +4 -1
  22. package/templates/api/screens/task/task.model.js +2 -2
  23. package/templates/api/workflow.js +32 -0
  24. package/templates/ui/package.json +2 -2
  25. package/templates/ui/src/App.jsx +20 -0
  26. package/templates/ui/src/api/flows.js +6 -0
  27. package/templates/ui/src/index.css +16 -0
  28. package/templates/ui/src/menu.js +1 -0
  29. package/templates/ui/src/pages/ConfigureUI.jsx +4 -0
  30. package/templates/ui/src/pages/EditTask.jsx +3 -3
  31. package/templates/ui/src/pages/FlowDesigner.jsx +30 -0
  32. package/templates/ui/src/pages/Flows.jsx +106 -0
  33. package/templates/ui/src/pages/FlowsHome.jsx +65 -0
  34. package/templates/ui/src/pages/Journey.jsx +33 -0
  35. package/templates/ui/src/pages/TasksHelp.jsx +3 -2
  36. package/templates/ui/vite.config.js +4 -1
  37. package/templates/api/screens/task/task.hooks.js +0 -37
package/README.md CHANGED
@@ -41,6 +41,19 @@ Already working, with nothing to wire up:
41
41
  - **Menu**: rename, reorder and hide the side rail's items.
42
42
 
43
43
  Nobody else sees it, and the API refuses everyone else.
44
+ - **Flows, in every app** — your forms one after another. Designed in
45
+ Configure UI → Flows: for each screen, the app's functions that run around
46
+ its records (its events) and where the journey goes once it is saved — a
47
+ screen, a function that decides, or the end. Walked at `/journey/<key>`, and
48
+ resumable. Run by `@xeplr/workflow-api` **inside the API**, in a database of
49
+ its own (`<name>_workflow`, created on first start) — nothing extra to start.
50
+ It follows the app's tenancy (none, or the levels you chose) and never creates
51
+ companies or workspaces of its own. Approvals (another person's turn) are not
52
+ part of it.
53
+ - **Functions** — `api/functions/`: the app's own code, registered with
54
+ `@xeplr/actions` by the event it is for (`beforeSave`, `afterSave`, …,
55
+ `decide`) and picked by name in a screen's events or a flow step. Two to start
56
+ from: `tidyTaskTitle` (Tasks' `beforeSave`) and `byPriority` (a `decide`).
44
57
  - **Multi-tenancy, if you ask for it** — companies (or companies and
45
58
  workspaces, up to four levels). Every row is stamped with the one it was made
46
59
  in, every read sees only its rows, membership is checked on every request,
@@ -71,7 +84,7 @@ These are architecture decisions, not defaults to tune — each one exists becau
71
84
  | **`REDIS_PREFIX` is required and unique per app** (`api/development.env`, set to `<name>:`). | `@xeplr/auth`, `@xeplr/utils` | Two apps on one Redis under the shared default serve each other's sessions, menus and API permissions. Sign-in refuses to start without it, or with `xeplr:`. |
72
85
  | **Designing, new forms and publishing are Super Admin only.** | `api/routes/access.js`, `@xeplr/auth` menu routes | Publishing changes database tables. Every other screen route checks the permission catalog. |
73
86
  | **The browser is not a security boundary.** Front-end hooks (`ui/src/pages/Edit<Form>.jsx`) shape what people see and send; anything that must hold goes in server hooks (`api/screens/<form>/<form>.hooks.js`); data shape in the model (`<form>.model.js`). | `@xeplr/ui-factory`, `@xeplr/factory` | |
74
- | **Anything every app would write identically lives in a package, not in the generated app.** A generated app is a starting point that then lives its own life — glue copied into it can never be fixed centrally, so an app made last month keeps that copy forever. When Configure UI → Masters needed to know which forms are masters, that answer went into `@xeplr/ui-factory` (`useScreenSource`) and the generated page only reads `masters`. | `@xeplr/ui-factory` | A fix or a new control type has to reach apps that already exist, and it cannot reach code that was copied into them. `test/cli.test.js` fails if the glue reappears in the generated app. |
87
+ | **Anything every app would write identically lives in a package, not in the generated app.** A generated app is a starting point that then lives its own life — glue copied into it can never be fixed centrally, so an app made last month keeps that copy forever. When the flow designer needed this app's forms, its functions and its flows, the wiring went into `@xeplr/ui-factory` (`FlowDesignerPage`) and the generated page passes its two API clients. | `@xeplr/ui-factory` | A fix or a new control type has to reach apps that already exist, and it cannot reach code that was copied into them. `test/cli.test.js` fails if the glue reappears in the generated app. |
75
88
  | **Every data address is under `/api`.** | `api/routes`, `ui/vite.config.js` | A page path and a data path must never collide. |
76
89
  | **No guessed database names or connections.** | `api/development.env` | A wrong-but-present value starts cleanly and reads as empty data. |
77
90
  | **Email is checked at start, never fatal.** The sign-in banner shows `email ✓ …` or `✗ NOT WORKING — reason`. | `@xeplr/auth` | Sign-in works without email; activation, invite and reset links cannot be sent until `EMAIL_PROVIDER` is set. |
@@ -119,6 +132,12 @@ see **Configure UI** in the settings menu, top right.
119
132
  (`<name>_auth`, `<name>_api`); an existing database is kept as it is, with its
120
133
  accounts — the super admin you enter is created only in a fresh one.
121
134
 
135
+ Dashes in the name become underscores in the database names: `my-app` gets
136
+ `my_app_auth` and `my_app_api`. `@xeplr/db` creates only plain identifiers
137
+ (letters, digits, `_`) because `CREATE DATABASE` cannot take its name as a
138
+ parameter, and the project name itself cannot hold `_` (it is also a folder and
139
+ a package name).
140
+
122
141
  `xeplr new myapp --no-install` only writes the files; then run `npm install` in
123
142
  `api` and `ui`, and `npm run setup` in `api`, yourself. If set-up stops part
124
143
  way (Postgres not running, say), the installer says which step and the command
@@ -139,7 +158,7 @@ Two ways, both in the running app's project:
139
158
  > (file: .pdf or .jpg), Notes (long text). Show Name, Region and Area in the
140
159
  > list. Follow "Create a new UI" in CLAUDE.md.
141
160
 
142
- Claude follows `CLAUDE.md`: the screens and server hooks and model
161
+ Claude follows `CLAUDE.md`: the screens and server functions and model
143
162
  (`npx xeplr-factory screens … --no-pages`), the page with its front-end hooks,
144
163
  the route, the side-rail item and its menu row. Then restart the sign-in
145
164
  service and the API — the API publishes the new screens and creates the table.
@@ -153,7 +172,7 @@ Two ways, both in the running app's project:
153
172
  | `api/` | `npm run start-api` | the API — runs migrations and publishes never-published screens on start |
154
173
  | `api/` | `npm run migrate:auth` | apply new `migrations-auth/*.sql` (e.g. a new menu row) |
155
174
  | `api/` | `npm run db:encrypt` | redo the encrypted database connection |
156
- | `api/` | `npx xeplr-factory screens screens/<form>/<form>.entity.json -o screens/<form> --no-pages` | a form's screens, server hooks and model from its spec |
175
+ | `api/` | `npx xeplr-factory screens screens/<form>/<form>.entity.json -o screens/<form> --no-pages` | a form's screens, server functions and model from its spec |
157
176
  | `api/` | `npx xeplr-factory validate <screen>.json` | check a screen document |
158
177
  | `ui/` | `npm run dev` | the app |
159
178
 
@@ -200,6 +219,17 @@ no defaults, ever.
200
219
 
201
220
  Encryption keys and signing secrets are **generated, never asked**.
202
221
 
222
+ **Flows, inside the API.** `@xeplr/workflow-api` is a dependency of `api/`,
223
+ and `bin/www` starts it with `registerWorkflow` after the screens — in a
224
+ database of its own (`DB_WORKFLOW`, `<name>_workflow`: a sub-product embedded in
225
+ an app keeps its tables apart, and creates the database on first start), with
226
+ `tenantTables: false` so it follows the app's tenancy rather than creating
227
+ `companies` / `workspaces`, and `access: true` so each flows route checks the
228
+ caller's permissions, which its `migrations-auth` grants (loaded through
229
+ `XEPLR_AUTH_MIGRATIONS`). The designer is `@xeplr/ui-factory`'s
230
+ `FlowDesignerPage`; the paid canvas designer (X-Flow) is not part of a
231
+ generated app.
232
+
203
233
  ## Licence
204
234
 
205
235
  MIT
@@ -24,6 +24,9 @@ var RENAME = {
24
24
  function substitutions(answers) {
25
25
  return Object.assign({
26
26
  __NAME__: answers.name,
27
+ __DB_API__: answers.databases.api,
28
+ __DB_AUTH__: answers.databases.auth,
29
+ __DB_WORKFLOW__: answers.databases.workflow,
27
30
  __PREFIX__: answers.prefix,
28
31
  __UI_PORT__: String(answers.ports.ui),
29
32
  __AUTH_PORT__: String(answers.ports.auth),
@@ -34,7 +37,25 @@ function substitutions(answers) {
34
37
  __ADMIN_PASSWORD__: answers.admin.password,
35
38
  __DB_CONNECTION__: answers.connection || '',
36
39
  __CONNECTION_NOTE__: connectionNote(answers)
37
- }, tenancySubstitutions(answers.tenancy || []));
40
+ }, tenancySubstitutions(answers.tenancy || []), flowsSubstitutions(answers));
41
+ }
42
+
43
+ /**
44
+ * FLOWS ARE IN EVERY APP, and so is what runs them: @xeplr/workflow, started
45
+ * INSIDE the API (bin/www → registerWorkflow) in the app's own database. There
46
+ * is no Workflow server to run, no port, no setting and nothing to connect, so
47
+ * nothing here depends on an answer — these tokens only carry text.
48
+ */
49
+ function flowsSubstitutions(answers) {
50
+ return {
51
+ __WF_README__: flowsReadme(answers),
52
+ __WF_CLAUDE__: [
53
+ '**Flows** — forms one after another, run by `@xeplr/workflow-api` INSIDE this API (bin/www starts it, workflow.js) in a database of its own (`DB_WORKFLOW`). Design them in Configure UI → Flows, never in code: a flow is data, reached through `/api/workflow/flows`, and walked at `/journey/<key>`. After a step, a flow goes to a screen, lets one of the app\'s `decide` functions choose, or finishes. Workflow follows this app\'s tenancy — it never creates companies or workspaces of its own.',
54
+ '',
55
+ '**Functions** — the code around a form\'s records lives in `api/functions/`, registered with `@xeplr/actions` under the event it is for (`beforeLoad`, `beforeSave`, `afterSave`, `beforeDelete`, `onError`, or `decide` for a flow), and is picked BY NAME in the screen\'s `events` or a flow step\'s navigation. There are no hook files. A screen naming a function nobody registered is refused at publish.',
56
+ ''
57
+ ].join('\n')
58
+ };
38
59
  }
39
60
 
40
61
  /**
@@ -118,6 +139,42 @@ function tenancySubstitutions(levels) {
118
139
  };
119
140
  }
120
141
 
142
+ /** The README's Flows section. */
143
+ function flowsReadme(answers) {
144
+ var tenancy = answers.tenancy || [];
145
+ return [
146
+ '## Flows',
147
+ '',
148
+ 'Your forms, one after another. Design one in **Configure UI → Flows**: pick the screens, and for each one say what runs around its records (its **events**) and where the journey goes once it is saved — a screen, a **function that decides**, or the end. Publish it, and people start it from **Flows** in the side rail (or at `/journey/<key>`). The run id joins the address once it starts, so a journey can be reloaded, left, or handed to someone else and opened on the step it is on.',
149
+ '',
150
+ 'Flows are run by **`@xeplr/workflow-api`**, **inside this API** — `bin/www` starts it after the screens. Nothing else to start:',
151
+ '',
152
+ '| | |',
153
+ '|---|---|',
154
+ '| **Database** | its own, `DB_WORKFLOW` (`' + answers.databases.workflow + '`), beside this app\'s — a sub-product embedded in an app keeps its tables apart. Created and migrated the first time the API starts. Each screen still saves into its own table in `DB_API`. |',
155
+ '| **Tenancy** | ' + (tenancy.length
156
+ ? 'this app\'s — flows and runs belong to the ' + tenancy.map(function (l) { return l.label.toLowerCase(); }).join(' and ') + ' chosen after signing in, checked by the same membership gate as everything else.'
157
+ : 'none, like the rest of this app — flows belong to the whole app.') + ' Workflow never creates companies or workspaces of its own. |',
158
+ '| **Permissions** | each route checks the caller\'s (`List flows`, `Start flow run`, `Publish flow`…), granted by `@xeplr/workflow-api`\'s `migrations-auth`, which `XEPLR_AUTH_MIGRATIONS` loads. |',
159
+ '',
160
+ '## Functions — your code, picked by name',
161
+ '',
162
+ 'Code around a form\'s records — a check on save, an email after, a list narrowed to the user\'s own — is a **function** in `api/functions/`, registered with `@xeplr/actions` under the **event** it is for. A screen names it in its `events`; a flow step names a `decide` function to choose where to go next. The designer shows each event only the functions written for it, with what they are handed and return; the code stays in `api/functions/` and runs on the server.',
163
+ '',
164
+ '| event | runs | return |',
165
+ '|---|---|---|',
166
+ '| `beforeLoad` | before records are read | nothing, or a narrower `ctx.query` |',
167
+ '| `beforeSave` | before a record is written | the values to save; `ctx.reject(message, { field })` refuses |',
168
+ '| `afterSave` | once it is saved | (ignored) |',
169
+ '| `beforeDelete` | before a delete | nothing; `ctx.reject` refuses |',
170
+ '| `onError` | when load / save / delete failed | (ignored) |',
171
+ '| `decide` | a flow step is done | the next step\'s key |',
172
+ '',
173
+ '`api/functions/index.js` registers two to start from: `tidyTaskTitle` (Tasks\' `beforeSave`) and `byPriority` (a `decide`). A screen that names a function nobody registered is refused when it is published, by name.',
174
+ ''
175
+ ].join('\n');
176
+ }
177
+
121
178
  /** One level's own table: what the picker lists. Its parents' ids in mtIdN. */
122
179
  function tenantTableSql(level, i) {
123
180
  var parents = i === 0 ? 'none — the outermost level' : i === 1 ? 'mtId1 holds the one above' : 'mtId1–mtId' + i + ' hold the ones above';
package/lib/plan.js CHANGED
@@ -18,6 +18,7 @@ function describe(answers) {
18
18
  L.push(' Databases (created for you on first run):');
19
19
  L.push(' ' + answers.databases.auth + ' accounts, roles, permissions');
20
20
  L.push(' ' + answers.databases.api + ' your own data');
21
+ L.push(' ' + answers.databases.workflow + ' flows and their runs');
21
22
  L.push('');
22
23
  L.push(' You will sign in as ' + answers.admin.email + '.');
23
24
  L.push('');
package/lib/questions.js CHANGED
@@ -20,6 +20,21 @@ function envPrefix(name) {
20
20
  return name.toUpperCase().replace(/-/g, '_');
21
21
  }
22
22
 
23
+ // What @xeplr/db's ensureDatabase accepts: CREATE DATABASE cannot take its
24
+ // name as a parameter, so it allows plain identifiers only.
25
+ var DB_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
26
+
27
+ function databaseNames(name) {
28
+ // my-app → my_app_auth, my_app_api. Dashes are fine in a folder and a
29
+ // package name but not in an unquoted identifier, and @xeplr/db refuses
30
+ // them ("Unsafe database name"). NAME_RE does not allow underscores, so
31
+ // without this every name with a separator in it failed at set-up.
32
+ var base = name.replace(/-/g, '_');
33
+ // Workflow gets a database of its own: a sub-product embedded in an app is
34
+ // given its own store, never a share of the app's (see the API's workflow.js).
35
+ return { auth: base + '_auth', api: base + '_api', workflow: base + '_workflow' };
36
+ }
37
+
23
38
  // ── tenancy ──────────────────────────────────────────────────────────────
24
39
  //
25
40
  // At most four levels — @xeplr/db has four tenant columns (mtId1-4) — named
@@ -134,6 +149,10 @@ async function collect(session, argvName) {
134
149
  tenancy = tenancyLevels(levelsText);
135
150
  }
136
151
 
152
+ // ── flows ─────────────────────────────────────────────────────────────
153
+ // Nothing to ask: every app has flows, run by @xeplr/workflow inside its own
154
+ // API and database (see lib/generate's flowsSubstitutions).
155
+
137
156
  // ── the database ──────────────────────────────────────────────────────
138
157
  //
139
158
  // ASKED, not defaulted, and not left for the reader to do by hand either.
@@ -171,7 +190,7 @@ async function collect(session, argvName) {
171
190
  name: name,
172
191
  prefix: envPrefix(name),
173
192
  ports: { ui: base, auth: base + 1, api: base + 2 },
174
- databases: { auth: name + '_auth', api: name + '_api' },
193
+ databases: databaseNames(name),
175
194
  admin: { email: adminEmail, password: adminPassword },
176
195
  tenancy: tenancy,
177
196
 
@@ -181,4 +200,4 @@ async function collect(session, argvName) {
181
200
  };
182
201
  }
183
202
 
184
- module.exports = { collect: collect, envPrefix: envPrefix, tenancyLevels: tenancyLevels, NAME_RE: NAME_RE };
203
+ module.exports = { collect: collect, envPrefix: envPrefix, databaseNames: databaseNames, DB_NAME_RE: DB_NAME_RE, tenancyLevels: tenancyLevels, NAME_RE: NAME_RE };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/cli",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Creates a working xeplr application — API, sign-in and UI — from a few answers.",
5
5
  "license": "MIT",
6
6
  "author": "xeppie <admin@xeplr.com>",
@@ -3,13 +3,13 @@
3
3
  An xeplr app: `api/` (Node, @xeplr/base-apis, sign-in by @xeplr/auth) and `ui/` (React + Vite, @xeplr/ui-account).
4
4
  Forms are **screens** made with @xeplr/ui-factory and served by @xeplr/factory. The sample is **Tasks**.
5
5
 
6
- Read `api/node_modules/@xeplr/ui-factory/AUTHORING.md` for what screens, hooks and models mean. This file says where they go in **this** app.
6
+ Read `api/node_modules/@xeplr/ui-factory/AUTHORING.md` for what screens, functions, front-end hooks and models mean. This file says where they go in **this** app.
7
7
 
8
8
  ## Create a new UI
9
9
 
10
10
  A new UI is a **form**: a list, and an add / edit form it opens — on a page of its own (like Tasks) or in a popup — saved in its own table. Use the singular for `<form>` (`farming_department`), the plural for the page (`FarmingDepartments`).
11
11
 
12
- Create every file below — the hooks and the model run the defaults (`super`) until the request needs more.
12
+ Create every file below — the front-end hooks and the model run the defaults (`super`) until the request needs more, and the server functions file registers nothing until it does.
13
13
 
14
14
  1. **The spec** — `api/screens/<form>/<form>.entity.json`: the name and the fields in reading order. Copy `api/screens/task/task.entity.json`; the format is "The entity spec" in AUTHORING.md. A dropdown on another form's table needs that form to exist first. **Use the ready-made fields** for email, phone, website, LinkedIn, age, date of birth, gender, country, amount, percentage, yes/no and postal code — `"type": "email"` and so on ("Choosing controls" in AUTHORING.md): they bring the keyboard, format check and options with them, so don't write those by hand.
15
15
  2. **Screens + server files** — from `api/`:
@@ -18,8 +18,8 @@ Create every file below — the hooks and the model run the defaults (`super`) u
18
18
  npx xeplr-factory validate screens/<form>/<form>-edit.screen.json
19
19
  npx xeplr-factory validate screens/<form>/<form>-list.screen.json
20
20
  ```
21
- Writes `<form>-list.screen.json`, `<form>-edit.screen.json`, `<form>.hooks.js` (server hooks), `<form>.model.js` (server model).
22
- 3. **Register them** in `api/screens/index.js`: both screens in `documents` (edit before list), `<form>_edit: require('./<form>/<form>.hooks')` in `hooks`, `require('./<form>/<form>.model')` in `models`.
21
+ Writes `<form>-list.screen.json`, `<form>-edit.screen.json`, `<form>.functions.js` (server functions, none registered yet), `<form>.model.js` (server model).
22
+ 3. **Register them** in `api/screens/index.js`: both screens in `documents` (edit before list), `require('./<form>/<form>.model')` in `models`; and in `api/functions/index.js`, `require('../screens/<form>/<form>.functions')`.
23
23
  4. **The form's page, with the front-end hooks** — `ui/src/pages/Edit<Form>.jsx`: copy `EditTask.jsx`; rename `TaskHooks` → `<Form>Hooks`, `taskHooks` → `<form>Hooks`, `'task_edit'` → `'<form>_edit'`. Keep every method calling `super`.
24
24
  5. **The list's page** — `ui/src/pages/<Forms>.jsx`: copy `Tasks.jsx`; load `'<form>_list'`, import `<form>Hooks` from `./Edit<Form>.jsx`, drop `TasksHelp`.
25
25
  6. **Route and side-rail item** — `ui/src/App.jsx`: import the list page, add `<Route path="/<forms>" element={<Forms />} />` next to `/tasks`, and a `drawerItems` entry `{ key: '<Forms>', icon, path: '/<forms>', clickHandler }` — by **key**, with an **icon** (a collapsed rail shows icons only) and the **path** (so the rail marks it as the current page). Never write the label in code.
@@ -33,11 +33,11 @@ Then change only what the request asks for:
33
33
  |---|---|
34
34
  | a field, label, rule, dropdown, layout, colour | the screen JSON → validate → publish in Configure UI → Forms (or, never published yet, restart the API) |
35
35
  | hide / filter rows on screen, an extra button on each row, fill in a value before saving, check something before Next moves on | front-end hooks — `ui/src/pages/Edit<Form>.jsx` (`get`, `save`, `delete`, `actions`, `step`) |
36
- | must / only if / check against / email when / only managers see | server hooks — `api/screens/<form>/<form>.hooks.js` |
36
+ | must / only if / check against / email when / only managers see | a server function — registered in `api/screens/<form>/<form>.functions.js` with its `event`, then named in `<form>-edit.screen.json`'s `events` (or picked in Configure UI → Flows → Events). Register before naming: a screen naming an unregistered function is refused at publish |
37
37
  | store as / convert / comma-separated ↔ array / work out X from Y | server model — `api/screens/<form>/<form>.model.js` |
38
38
  | a query in your own server code | `factory.table('<forms table>')` — this company, active rows, the model applied |
39
39
 
40
- **Without code:** Super Admin can also make a form in **Configure UI → Forms** (settings menu, top right): New form (label + key) → design → Publish → Add to menu. It gets no page, hooks or model files; add them with the steps above when it needs them.
40
+ **Without code:** Super Admin can also make a form in **Configure UI → Forms** (settings menu, top right): New form (label + key) → design → Publish → Add to menu. It gets no page, functions or model files; add them with the steps above when it needs them.
41
41
 
42
42
  ### The controls a field can be
43
43
 
@@ -49,6 +49,7 @@ Then change only what the request asks for:
49
49
 
50
50
  **A list opens its form on a page or in a popup** — `"openIn": "page"` in the entity spec, or Configure UI → Forms → the list. A page needs `onOpenRecord` on the list's page (where to navigate) and `onDone` on the form's page (where Save and Cancel go back to): `Tasks.jsx` and `EditTask.jsx` show both, with routes `/tasks/new` and `/tasks/:id` in `App.jsx`.
51
51
 
52
+ __WF_CLAUDE__
52
53
  **Keys and labels.** A form's key names its screens and table and never changes once published; its label is the screens' name. A menu item's key (`menus.name`) is what `drawerItems` matches; its label is what the rail shows, renamed in Configure UI → Menu. Code only ever uses keys.
53
54
 
54
55
  ### The prompt
@@ -64,6 +65,7 @@ Replace the bold parts. Add behaviour in the same message if you want it — "a
64
65
  - A published screen's design lives in the **database**. Editing its `.screen.json` changes nothing afterwards: publish the change in Configure UI → Forms.
65
66
  - Every data address is under `/api` (a page and a data path must never collide). New routes go in `api/routes/index.js`, and `ui/vite.config.js` must proxy their prefix.
66
67
  - Designing and publishing are **Super Admin only** (`api/routes/access.js`); every other route checks the permission catalog.
67
- - The browser is not a security boundary: anything that must hold goes in server hooks.
68
+ - The browser is not a security boundary: anything that must hold goes in a server function, named in the screen's events.
68
69
  - Hooks and models are **methods** calling `super` — never arrow-function properties.
69
70
  - `authFetch` returns the parsed body, not a Response.
71
+ - Sign-up needs email and `AUTH_REGISTER_ROLES` (the role a new account gets, in `api/development.env`); without either, registration is refused on purpose — never work around it by activating accounts or granting roles in SQL. An admin can activate an account from the User Roles screen.
@@ -21,6 +21,9 @@ cd ui && npm run dev # the app http://localhost:__UI_PORT_
21
21
 
22
22
  Open http://localhost:__UI_PORT__ and sign in as `__ADMIN_EMAIL__`.
23
23
 
24
+ Flows (`@xeplr/workflow-api`) run **inside the API**, in a database of their
25
+ own that the API creates on first start — nothing else to start.
26
+
24
27
  ## Where each address goes
25
28
 
26
29
  The browser only ever calls **paths** on the app's own address — never a port.
@@ -30,6 +33,7 @@ locally (`ui/vite.config.js`, from `ui/.env`), nginx in production.
30
33
  | path | served by | local (`ui/.env`) | production (nginx) |
31
34
  |---|---|---|---|
32
35
  | `/auth/api/…` | the sign-in service | `AUTH_URL` | `127.0.0.1:<AUTH_PORT>` |
36
+ | `/api/workflow/…` | flows — `@xeplr/workflow-api`, inside the API | `API_URL` | `127.0.0.1:<__PREFIX___PORT>` |
33
37
  | `/api/…`, `/health`, `/whoami` | the API | `API_URL` | `127.0.0.1:<__PREFIX___PORT>` |
34
38
 
35
39
  ### Production
@@ -43,11 +47,11 @@ server {
43
47
  server_name app.example.com;
44
48
  root /srv/__NAME__/ui/dist;
45
49
 
46
- location /auth/api/ { proxy_pass http://127.0.0.1:__AUTH_PORT__; }
47
- location /api/ { proxy_pass http://127.0.0.1:__API_PORT__; }
48
- location = /health { proxy_pass http://127.0.0.1:__API_PORT__; }
49
- location = /whoami { proxy_pass http://127.0.0.1:__API_PORT__; }
50
- location / { try_files $uri /index.html; } # the React app's own pages
50
+ location /auth/api/ { proxy_pass http://127.0.0.1:__AUTH_PORT__; }
51
+ location /api/ { proxy_pass http://127.0.0.1:__API_PORT__; }
52
+ location = /health { proxy_pass http://127.0.0.1:__API_PORT__; }
53
+ location = /whoami { proxy_pass http://127.0.0.1:__API_PORT__; }
54
+ location / { try_files $uri /index.html; } # the React app's own pages
51
55
  }
52
56
  ```
53
57
 
@@ -57,7 +61,9 @@ Every one of these checks the same sign-in token, so one login covers them all.
57
61
 
58
62
  - **Sign-in** — login, register, password reset, profile, and the admin screens for users, roles and permissions (`@xeplr/auth`, `@xeplr/ui-account`).
59
63
  - **Tasks** — a sample built from screens: a list, and a form it opens on a page of its own, saved into an ordinary `tasks` table. The form is filled in over three steps and uses every control there is — text, a dropdown, radio buttons, a tick box, a date, a date and time, several tags at once, and a file.
60
- - **Configure UI** — in the settings menu, for Super Admin: every form (make one, design it, publish it, add it to the side rail) and the side rail itself (rename, reorder, hide).
64
+ - **Configure UI** — in the settings menu, for Super Admin: every form (make one, design it, publish it, add it to the side rail), every flow (screens one after another — each screen's events, and where it goes next) and the side rail itself (rename, reorder, hide).
65
+ - **Flows** — in the side rail: the journeys people can start, run by `@xeplr/workflow-api` inside the API.
66
+ - **Functions** — `api/functions/`: your code, registered by event and picked by name in a screen's events or a flow step (see "Functions" below).
61
67
 
62
68
  ## Create a new UI
63
69
 
@@ -66,6 +72,7 @@ Every one of these checks the same sign-in token, so one login covers them all.
66
72
 
67
73
  > Create a new UI for farming departments. One record is a farming department with: Name (required), Region (dropdown: North, South, East, West), Area in acres (number, at least 0), Started on (date), Notes (long text). Show Name, Region and Area in the list. Follow "Create a new UI" in CLAUDE.md.
68
74
 
75
+ __WF_README__
69
76
  ## Rules this app follows
70
77
 
71
78
  | Rule | Why |
@@ -74,7 +81,7 @@ Every one of these checks the same sign-in token, so one login covers them all.
74
81
  | A **key** and a **label**, everywhere: code uses keys (form keys, menu keys); people read labels, renamed in Configure UI. | Renaming what people see never breaks code. |
75
82
  | **`REDIS_PREFIX`** in `api/development.env` is required and unique to this app. | Apps sharing a Redis would otherwise serve each other's sessions and permissions. |
76
83
  | Designing and publishing forms is **Super Admin only**. | Publishing changes database tables. |
77
- | Rules that must hold go in **server hooks**; the browser only shapes what people see and send. | The browser is not a security boundary. |
84
+ | Rules that must hold go in **server functions** (`api/functions/`), named in a screen's events; the browser only shapes what people see and send. | The browser is not a security boundary. |
78
85
  | Every data address is under **`/api`**. | A page and a data path must never collide. |
79
86
 
80
87
  The full list, and where each piece lives, is in `CLAUDE.md`.
@@ -6,13 +6,13 @@ The API (`@xeplr/base-apis`) and the sign-in service (`@xeplr/auth`), with this
6
6
 
7
7
  | Command | Does |
8
8
  |---|---|
9
- | `npm run setup` | create both databases (`__NAME___auth`, `__NAME___api`), the sign-in tables, permissions and menus |
9
+ | `npm run setup` | create both databases (`__DB_AUTH__`, `__DB_API__`), the sign-in tables, permissions and menus |
10
10
  | `npm run start-auth` | the sign-in service, port __AUTH_PORT__ — refuses to start without Redis or `REDIS_PREFIX`; its banner says whether email works |
11
11
  | `npm run start-api` | the API, port __API_PORT__ — runs `migrations/` and publishes never-published screens on start |
12
12
  | `npm run migrate:auth` | apply new `migrations-auth/*.sql` (a new menu row, say), then restart sign-in |
13
13
  | `npm run db:encrypt` | redo the encrypted database connection |
14
14
  | `npm run check-env` | check every required setting is present |
15
- | `npx xeplr-factory screens screens/<form>/<form>.entity.json -o screens/<form> --no-pages` | a form's screens, server hooks and model from its spec |
15
+ | `npx xeplr-factory screens screens/<form>/<form>.entity.json -o screens/<form> --no-pages` | a form's screens, server functions and model from its spec |
16
16
  | `npx xeplr-factory validate <screen>.json` | check a screen document |
17
17
 
18
18
  ## Layout
@@ -24,7 +24,9 @@ The API (`@xeplr/base-apis`) and the sign-in service (`@xeplr/auth`), with this
24
24
  | `app.js` | the Express app: the auth gate, and the routes |
25
25
  | `routes/index.js` | this app's own routes — every data address under `/api` |
26
26
  | `routes/access.js` | who may design and publish forms (Super Admin) |
27
- | `screens/` | the forms this app ships with: per form, its spec, list and edit screens, server hooks and model — registered in `screens/index.js` |
27
+ | `screens/` | the forms this app ships with: per form, its spec, list and edit screens, server functions and model — registered in `screens/index.js` |
28
+ | `functions/` | this app's functions — the code screens' events and flows' `decide` pick by name — registered with `@xeplr/actions` by event, loaded at startup |
29
+ | `workflow.js` | flows: `@xeplr/workflow-api` started inside this API, in its own database (`DB_WORKFLOW`) |
28
30
  | `uploads/` | what people attach through a form's **file** field (`FACTORY_FILES_DIR`). Never committed, and never served: a file comes back only through the API's own route, which checks the caller the way reading a record does |
29
31
  | `migrations/` | hand-written SQL for tables no form owns |
30
32
  | `migrations-auth/` | this app's rows in the sign-in database — menus (by key), permissions |
@@ -39,4 +41,10 @@ The API (`@xeplr/base-apis`) and the sign-in service (`@xeplr/auth`), with this
39
41
  | `DB_API`, `AUTH_DB_NAME` | the two database names — never guessed |
40
42
  | `AUTH_SUPER_ADMIN_EMAIL` / `_PASSWORD` | the first account — created once, when the sign-in database is first set up |
41
43
  | `AUTH_TENANT_HEADER` | `none`: one set of roles for everyone. A multi-tenant app names its company header (e.g. `x-company-id`), so a role made inside a company belongs to it |
42
- | `EMAIL_PROVIDER` and its settings | optional; without them sign-in works, but activation and reset links cannot be sent |
44
+ | `AUTH_ACTIVATION_URL`, `AUTH_INVITE_URL`, `AUTH_RESET_URL` | the UI pages the emailed links open; the token is appended. The reset address is never taken from the request |
45
+ | `AUTH_REGISTER_ROLES` | the role a new sign-up gets when activated (`Viewer`). **Required to register:** unset, or naming a role that does not exist, and registration is refused |
46
+ | `EMAIL_PROVIDER` and its settings | needed to register, resend an activation link, invite or reset a password — each is refused while email does not work. Signing in works without it |
47
+
48
+ ## Sign-up
49
+
50
+ Register → an activation email → the link (or an admin's **Activate** on the User Roles screen) → sign in. Lost the email? `/auth/resend-activation` sends a new one. Invited people set their password at `/auth/accept-invite`.
@@ -13,6 +13,8 @@ var memberGate = __MT_MEMBER_GATE__;
13
13
  var port = process.env.__PREFIX___PORT;
14
14
 
15
15
  module.exports = buildApp;
16
+ // bin/www hands the same gate to the flows router it starts (workflow.js).
17
+ module.exports.memberGate = memberGate;
16
18
 
17
19
  function buildApp(extraRoutes) {
18
20
  return createApp(port, '__NAME___api', {
@@ -33,6 +33,10 @@ var path = require('path');
33
33
  var { resolveConfig } = require('@xeplr/db');
34
34
  var { up } = require('@xeplr/db').sqlMigrator;
35
35
  var factory = require('@xeplr/factory');
36
+ // This app's functions — what screens' events and flows' "let a function
37
+ // decide" pick by name. Registered before anything can publish a screen that
38
+ // names one.
39
+ require('../functions');
36
40
  var screens = require('../screens');
37
41
 
38
42
  async function start() {
@@ -58,15 +62,13 @@ async function start() {
58
62
  // time has no connection at all until this has finished.
59
63
  var knex = await require('../db/setup');
60
64
 
61
- // The screens, their hooks and their models. A hooks file with a typo
62
- // ("beforeSave") or a malformed model stops startup here, rather than
63
- // silently never running.
65
+ // The screens and their models. A malformed model stops startup here,
66
+ // rather than silently never running.
64
67
  // filesDir: where a form's file field puts what people upload. Nothing is
65
68
  // served from it — a file comes back only through the factory's own route,
66
69
  // which checks the caller the way reading a record does.
67
70
  await factory.init({
68
71
  knex: knex,
69
- hooks: screens.hooks,
70
72
  models: screens.models,
71
73
  filesDir: process.env.FACTORY_FILES_DIR
72
74
  });
@@ -76,7 +78,13 @@ async function start() {
76
78
  var published = await factory.publishScreens(screens.documents);
77
79
  if (published.published.length) console.log('[api] published screens: ' + published.published.join(', '));
78
80
 
79
- require('../app')();
81
+ // FLOWS — @xeplr/workflow-api, inside this API, in a database of its own
82
+ // (DB_WORKFLOW — created and migrated on first start). It follows this
83
+ // app's tenancy and creates no companies or workspaces of its own.
84
+ var buildApp = require('../app');
85
+ require('../routes/flows').mount(await require('../workflow')(buildApp.memberGate));
86
+
87
+ buildApp();
80
88
  }
81
89
 
82
90
  start().catch(function(err) {
@@ -4,7 +4,7 @@
4
4
  var { getConnection } = require('@xeplr/db');
5
5
  __MT_DB_REGISTER__
6
6
 
7
- // No `|| '__NAME___api'` fallback. DB_API is required at startup
7
+ // No `|| '__DB_API__'` fallback. DB_API is required at startup
8
8
  // (env.required.js), so a default could only ever fire in a process that
9
9
  // skipped that check — and silently connecting to a database nobody named is
10
10
  // exactly what the check exists to prevent.
@@ -18,10 +18,12 @@ __PREFIX___CONNECTION=__DB_CONNECTION__
18
18
  AUTH_DB_CONNECTION_INFO_ENCRYPTED=__DB_CONNECTION__
19
19
 
20
20
  # ── Databases ──
21
- # Two of them: accounts in one, your own data in the other. Both are created
22
- # for you the first time you run the setup.
23
- DB_API=__NAME___api
24
- AUTH_DB_NAME=__NAME___auth
21
+ # Three of them: accounts, your own data, and flows (workflow's own, kept apart
22
+ # from yours). The first two are created by the setup; the flows one by the API
23
+ # the first time it starts.
24
+ DB_API=__DB_API__
25
+ AUTH_DB_NAME=__DB_AUTH__
26
+ DB_WORKFLOW=__DB_WORKFLOW__
25
27
 
26
28
  # ── Sign-in service ──
27
29
  #
@@ -40,9 +42,10 @@ AUTH_JWT_SECRET=__JWT_SECRET__
40
42
  # Where the sign-in service is. Your API asks it to check every token.
41
43
  AUTH_URL=http://localhost:__AUTH_PORT__
42
44
 
43
- # Sign-in setup files: the screens' permissions (@xeplr/factory's), then this
44
- # app's own — menu entries and anything else it adds. Comma-separated, in order.
45
- XEPLR_AUTH_MIGRATIONS=./node_modules/@xeplr/factory/migrations-auth,./migrations-auth
45
+ # Sign-in setup files: the screens' permissions (@xeplr/factory's), flows'
46
+ # (@xeplr/workflow-api's), then this app's own — menu entries and anything else
47
+ # it adds. Comma-separated, in order.
48
+ XEPLR_AUTH_MIGRATIONS=./node_modules/@xeplr/factory/migrations-auth,./node_modules/@xeplr/workflow-api/migrations-auth,./migrations-auth
46
49
 
47
50
  # ── The first account you sign in with ──
48
51
  # Created for you when you set up the database. Change the password here and
@@ -62,6 +65,16 @@ AUTH_TENANT_HEADER=none
62
65
  # email template can carry a token as a value but cannot build an address.
63
66
  AUTH_ACTIVATION_URL=http://localhost:__UI_PORT__/auth/activate
64
67
  AUTH_INVITE_URL=http://localhost:__UI_PORT__/auth/accept-invite
68
+ # Never taken from the request: a link built from its Host header could point
69
+ # a real user's reset mail at someone else's site. Unset, reset is refused.
70
+ AUTH_RESET_URL=http://localhost:__UI_PORT__/auth/reset-password
71
+
72
+ # ── The role a new sign-up gets ──
73
+ # Given when the account is activated. Without a role a person can sign in
74
+ # and do nothing — not even open their own profile. No default: unset, or a
75
+ # role that does not exist, and registration is refused. Comma-separate for
76
+ # more than one; Super Admin is never allowed here.
77
+ AUTH_REGISTER_ROLES=Viewer
65
78
 
66
79
  # ── Uploaded files ──
67
80
  # Where a form's FILE field puts what people upload. The path is what the
@@ -69,10 +82,12 @@ AUTH_INVITE_URL=http://localhost:__UI_PORT__/auth/accept-invite
69
82
  # which checks the caller the way reading a record does. Relative to api/.
70
83
  FACTORY_FILES_DIR=./uploads/factory
71
84
 
72
- # ── Email (optional) ──
73
- # Without it sign-in works, but activation, invite and password-reset links
74
- # cannot be sent. The sign-in service checks it at start and says so in its
75
- # banner: "email ✓ smtp — host:587, connected" or "✗ NOT WORKING — reason".
85
+ # ── Email ──
86
+ # Needed for anyone but the super admin to get in: registration, resending an
87
+ # activation link, invites and password reset all send a link, and each is
88
+ # refused while email does not work — no half-made accounts. Signing in keeps
89
+ # working without it. The sign-in service checks it at start and says so in
90
+ # its banner: "email ✓ smtp — host:587, connected" or "✗ NOT WORKING — reason".
76
91
  # Providers: smtp | brevo | aws | azure — see @xeplr/utils' README.
77
92
  # EMAIL_PROVIDER=smtp
78
93
  # SMTP_HOST=
@@ -80,4 +95,3 @@ FACTORY_FILES_DIR=./uploads/factory
80
95
  # SMTP_USER=
81
96
  # SMTP_PASS=
82
97
  # SMTP_FROM=
83
- # (smtp also needs `npm install nodemailer` in this folder)
@@ -9,7 +9,9 @@
9
9
  */
10
10
  module.exports = [
11
11
  // ENCRYPTION_KEY, AUTH_JWT_SECRET, AUTH_PORT, AUTH_DB_NAME,
12
- // XEPLR_AUTH_MIGRATIONS, AUTH_SUPER_ADMIN_EMAIL / _PASSWORD, REDIS_PREFIX
12
+ // XEPLR_AUTH_MIGRATIONS, AUTH_SUPER_ADMIN_EMAIL / _PASSWORD, REDIS_PREFIX,
13
+ // AUTH_TENANT_HEADER, and the sign-up settings: AUTH_REGISTER_ROLES,
14
+ // AUTH_ACTIVATION_URL, AUTH_RESET_URL
13
15
  ...require('@xeplr/auth').requiredEnv,
14
16
 
15
17
  // ── this app's own ──
@@ -26,4 +28,8 @@ module.exports = [
26
28
  // error — the most expensive way for this to go wrong.
27
29
  'DB_API',
28
30
  '__PREFIX___CONNECTION',
31
+
32
+ // Flows' own database (DB_WORKFLOW) — workflow is embedded in this API but
33
+ // keeps its tables apart from yours. The library names it.
34
+ ...require('@xeplr/workflow-api').embedRequiredEnv,
29
35
  ];
@@ -0,0 +1,47 @@
1
+ // THIS APP'S FUNCTIONS — the code a screen's events and a flow's "let a
2
+ // function decide" run. Loaded once at startup (bin/www), before the screens.
3
+ //
4
+ // A function is registered under the EVENT it is written for, and picked BY
5
+ // NAME in the designer: Configure UI → Flows → a step → Events & navigation
6
+ // (or a screen's own `events` in its .screen.json). The designer only ever
7
+ // sees the name, the description and what the event hands it — the code
8
+ // stays here, and runs on the server.
9
+ //
10
+ // event runs ctx adds return
11
+ // beforeLoad before records are read query (narrow it) nothing, or a new query
12
+ // beforeSave before a record is written values, isNew, the values to save
13
+ // previous
14
+ // afterSave once it is saved result (ignored)
15
+ // beforeDelete before a delete previous nothing
16
+ // onError when load / save / delete failed error (ignored)
17
+ // decide a flow step is done: where next? values, targets a step key
18
+ //
19
+ // Every ctx has user, tenant, knex, db() (this form's table, scoped) and
20
+ // reject(message, { field }) — which refuses with the message on that field.
21
+ //
22
+ // A screen that names a function nobody registered is refused when it is
23
+ // published, by name — so renaming one here means renaming it there too.
24
+ // Add files beside this one and require them below as the app grows.
25
+ var { register } = require('@xeplr/actions');
26
+
27
+ register({
28
+ name: 'tidyTaskTitle',
29
+ event: 'beforeSave',
30
+ description: 'Trims a task\'s title and squeezes repeated spaces, so " Plant seeds " is saved as "Plant seeds".',
31
+ execute: function(ctx) {
32
+ var title = String(ctx.values.title || '').replace(/\s+/g, ' ').trim();
33
+ if (!title) ctx.reject('A task needs a title', { field: 'title' });
34
+ return Object.assign({}, ctx.values, { title: title });
35
+ }
36
+ });
37
+
38
+ register({
39
+ name: 'byPriority',
40
+ event: 'decide',
41
+ description: 'High-priority tasks go to the first screen it may choose; everything else to the second.',
42
+ execute: function(ctx) {
43
+ // ctx.targets: the steps ticked under "It may choose", in the flow's order.
44
+ var high = ctx.values && ctx.values.priority === 'high';
45
+ return high ? ctx.targets[0] : (ctx.targets[1] || ctx.targets[0]);
46
+ }
47
+ });
@@ -0,0 +1,14 @@
1
+ -- "Flows" — the side-rail item for journeys across the app's forms.
2
+ --
3
+ -- ALWAYS THERE: flows are run by @xeplr/workflow inside this app's own API,
4
+ -- so there is nothing to switch on.
5
+ --
6
+ -- The name is the KEY: it must match ui/src/App.jsx's drawerItems `key`
7
+ -- EXACTLY. What people read is the label, changed in Configure UI → Menu.
8
+ -- (0005, not 0004: a multi-tenant app's own 0004 is the switch menu.)
9
+
10
+ INSERT INTO "menus" (id, name, "menuGroup", "isPublic", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
11
+ SELECT encode(gen_random_bytes(12), 'hex'), 'Flows', '', true, true, '*', now(), now()
12
+ WHERE NOT EXISTS (SELECT 1 FROM "menus" WHERE name = 'Flows');
13
+
14
+ UPDATE "menus" SET "isPublic" = true WHERE name = 'Flows';