@voxgig/build 4.4.0 → 4.11.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 +51 -2
- package/build.ts +20 -0
- package/dist/api/api_gen.d.ts +7 -0
- package/dist/api/api_gen.js +370 -0
- package/dist/api/api_gen.js.map +1 -0
- package/dist/build.d.ts +16 -1
- package/dist/build.js +17 -1
- package/dist/build.js.map +1 -1
- package/dist/doc/doc_gen.d.ts +8 -0
- package/dist/doc/doc_gen.js +397 -0
- package/dist/doc/doc_gen.js.map +1 -0
- package/dist/env/web/web_gen.js +159 -1
- package/dist/env/web/web_gen.js.map +1 -1
- package/dist/shape/ent.js +4 -2
- package/dist/shape/ent.js.map +1 -1
- package/env/web/web_gen.ts +174 -1
- package/package.json +6 -9
- package/tm/web/backend/env/shared/seed.ts.frag +23 -0
- package/tm/web/backend/env/web/api.ts.frag +157 -0
- package/tm/web/backend/env/web/web.ts.frag +37 -4
- package/tm/web/backend/srv/api/api-srv.ts.frag +4 -0
- package/tm/web/backend/srv/api/expose.ts.frag +33 -0
- package/tm/web/backend/srv/api/get_info.ts.frag +6 -0
- package/tm/web/backend/srv/api/on_ent.ts.frag +124 -0
- package/tm/web/backend/srv/auth/apikey_util.ts.frag +10 -0
- package/tm/web/backend/srv/auth/auth-srv.ts.frag +1 -0
- package/tm/web/backend/srv/auth/change_pass.ts.frag +12 -0
- package/tm/web/backend/srv/auth/create_apikey.ts.frag +33 -0
- package/tm/web/backend/srv/auth/get_info.ts.frag +1 -0
- package/tm/web/backend/srv/auth/list_apikey.ts.frag +15 -0
- package/tm/web/backend/srv/auth/load_auth.ts.frag +10 -1
- package/tm/web/backend/srv/auth/remind_pass.ts.frag +35 -0
- package/tm/web/backend/srv/auth/revoke_apikey.ts.frag +23 -0
- package/tm/web/backend/srv/auth/signin_user.ts.frag +12 -2
- package/tm/web/backend/srv/auth/signout_user.ts.frag +11 -1
- package/tm/web/backend/srv/auth/update_user.ts.frag +15 -0
- package/tm/web/backend/srv/auth/user_util.ts.frag +21 -0
- package/tm/web/backend/srv/auth/web_change_pass.ts.frag +16 -0
- package/tm/web/backend/srv/auth/web_create_apikey.ts.frag +15 -0
- package/tm/web/backend/srv/auth/web_list_apikey.ts.frag +11 -0
- package/tm/web/backend/srv/auth/web_load_auth.ts.frag +1 -0
- package/tm/web/backend/srv/auth/web_remind_pass.ts.frag +7 -0
- package/tm/web/backend/srv/auth/web_revoke_apikey.ts.frag +14 -0
- package/tm/web/backend/srv/auth/web_signin_user.ts.frag +1 -0
- package/tm/web/backend/srv/auth/web_signout_user.ts.frag +11 -3
- package/tm/web/backend/srv/auth/web_update_user.ts.frag +11 -0
- package/tm/web/backend/srv/ent/access.ts.frag +179 -0
- package/tm/web/backend/srv/ent/cmd_list.ts.frag +86 -0
- package/tm/web/backend/srv/ent/cmd_load.ts.frag +56 -0
- package/tm/web/backend/srv/ent/cmd_remove.ts.frag +60 -0
- package/tm/web/backend/srv/ent/cmd_save.ts.frag +141 -0
- package/tm/web/backend/srv/ent/ent-srv.ts.frag +4 -0
- package/tm/web/backend/srv/ent/get_info.ts.frag +6 -0
- package/tm/web/backend/srv/ent/web_cmd_list.ts.frag +17 -0
- package/tm/web/backend/srv/ent/web_cmd_load.ts.frag +17 -0
- package/tm/web/backend/srv/ent/web_cmd_remove.ts.frag +17 -0
- package/tm/web/backend/srv/ent/web_cmd_save.ts.frag +17 -0
- package/tm/web/backend/test/unit/env/web/api-router.test.ts.frag +315 -0
- package/tm/web/backend/test/unit/env/web/surface.test.ts.frag +109 -0
- package/tm/web/backend/test/unit/srv/api/api.setup.ts.frag +162 -0
- package/tm/web/backend/test/unit/srv/api/api.test.ts.frag +225 -0
- package/tm/web/backend/test/unit/srv/auth/apikey.test.ts.frag +152 -0
- package/tm/web/backend/test/unit/srv/auth/auth.setup.ts.frag +40 -0
- package/tm/web/backend/test/unit/srv/auth/session.test.ts.frag +189 -0
- package/tm/web/backend/test/unit/srv/ent/ent.setup.ts.frag +53 -0
- package/tm/web/backend/test/unit/srv/ent/proxy.test.ts.frag +109 -0
- package/tm/web/docs/explanation/web-architecture.md.frag +50 -0
- package/tm/web/docs/how-to/add-a-custom-entity-view.md.frag +53 -0
- package/tm/web/docs/how-to/change-the-theme.md.frag +63 -0
- package/tm/web/docs/how-to/customise-the-web-app.md.frag +68 -0
- package/tm/web/docs/how-to/use-the-api.md.frag +53 -0
- package/tm/web/docs/reference/web-app.md.frag +98 -0
- package/tm/web/web/AGENTS.md.frag +52 -0
- package/tm/web/web/e2e/smoke.spec.js.frag +13 -11
- package/tm/web/web/index.html.frag +1 -0
- package/tm/web/web/package.json.frag +1 -0
- package/tm/web/web/playwright.config.js.frag +2 -1
- package/tm/web/web/src/api.js.frag +92 -0
- package/tm/web/web/src/bus.js.frag +14 -8
- package/tm/web/web/src/cmp/admin.js.frag +293 -212
- package/tm/web/web/src/cmp/admin.md.frag +51 -0
- package/tm/web/web/src/cmp/app.js.frag +12 -15
- package/tm/web/web/src/cmp/app.md.frag +28 -0
- package/tm/web/web/src/cmp/auth.js.frag +69 -41
- package/tm/web/web/src/cmp/auth.md.frag +36 -0
- package/tm/web/web/src/cmp/public.js.frag +58 -0
- package/tm/web/web/src/cmp/public.md.frag +26 -0
- package/tm/web/web/src/cmp/settings.js.frag +178 -0
- package/tm/web/web/src/cmp/settings.md.frag +30 -0
- package/tm/web/web/src/cmp/shell.js.frag +231 -0
- package/tm/web/web/src/cmp/shell.md.frag +49 -0
- package/tm/web/web/src/cmp/view/custom-view.js.frag +47 -0
- package/tm/web/web/src/cmp/view/custom-view.md.frag +31 -0
- package/tm/web/web/src/custom.css.frag +8 -0
- package/tm/web/web/src/customise.js.frag +30 -0
- package/tm/web/web/src/hooks.js.frag +79 -0
- package/tm/web/web/src/main.js.frag +17 -4
- package/tm/web/web/src/model.js.frag +162 -0
- package/tm/web/web/src/style.css.frag +135 -21
- package/tm/web/web/src/theme.js.frag +82 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { test, describe } from 'node:test'
|
|
2
|
+
import assert from 'node:assert'
|
|
3
|
+
|
|
4
|
+
import { makeSeneca, as } from './ent.setup'
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
// The browser proxies: aim:web,on:ent,cmd:* -> aim:ent,cmd:*
|
|
8
|
+
//
|
|
9
|
+
// A browser may only send aim:web, so these four actions ARE the entity
|
|
10
|
+
// surface. Two things matter: they forward faithfully, and they are not a
|
|
11
|
+
// way around the rules the real commands enforce.
|
|
12
|
+
//
|
|
13
|
+
// Forwarding is checked on the action functions directly - they are plain
|
|
14
|
+
// modules, so a fake `this` records what they post. No boot, no store, and
|
|
15
|
+
// nothing that depends on which entities this model happens to have.
|
|
16
|
+
|
|
17
|
+
const ALICE = { id: 'u-alice', email: 'alice@ex.com' }
|
|
18
|
+
|
|
19
|
+
const OPS = ['list', 'load', 'save', 'remove']
|
|
20
|
+
|
|
21
|
+
function proxyAction(op: string) {
|
|
22
|
+
return require('../../../../dist/srv/ent/web_cmd_' + op + '.js')()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A `this` that records posts instead of sending them.
|
|
26
|
+
function recorder(result: any = { ok: true }) {
|
|
27
|
+
const posts: any[] = []
|
|
28
|
+
return {
|
|
29
|
+
posts,
|
|
30
|
+
ctx: {
|
|
31
|
+
post: async (pattern: string, msg: any) => {
|
|
32
|
+
posts.push({ pattern, msg })
|
|
33
|
+
return result
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
describe('entity browser proxies', () => {
|
|
41
|
+
|
|
42
|
+
test('each proxy forwards to its own entity command', async () => {
|
|
43
|
+
for (const op of OPS) {
|
|
44
|
+
const rec = recorder()
|
|
45
|
+
await proxyAction(op).call(rec.ctx, { ent: 'x/y' })
|
|
46
|
+
assert.strictEqual(rec.posts.length, 1, op)
|
|
47
|
+
assert.strictEqual(rec.posts[0].pattern, 'aim:ent,cmd:' + op, op)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
test('arguments travel through unchanged', async () => {
|
|
53
|
+
const rec = recorder()
|
|
54
|
+
|
|
55
|
+
await proxyAction('list').call(rec.ctx, { ent: 'x/y', q: { a: 1 } })
|
|
56
|
+
assert.deepStrictEqual(rec.posts[0].msg.q, { a: 1 })
|
|
57
|
+
assert.strictEqual(rec.posts[0].msg.ent, 'x/y')
|
|
58
|
+
|
|
59
|
+
await proxyAction('load').call(rec.ctx, { ent: 'x/y', id: 'e01' })
|
|
60
|
+
assert.strictEqual(rec.posts[1].msg.id, 'e01')
|
|
61
|
+
|
|
62
|
+
await proxyAction('save').call(rec.ctx, { ent: 'x/y', item: { t: 'v' } })
|
|
63
|
+
assert.deepStrictEqual(rec.posts[2].msg.item, { t: 'v' })
|
|
64
|
+
|
|
65
|
+
await proxyAction('remove').call(rec.ctx, { ent: 'x/y', id: 'e01' })
|
|
66
|
+
assert.strictEqual(rec.posts[3].msg.id, 'e01')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
test('the result is returned unchanged', async () => {
|
|
71
|
+
const rec = recorder({ ok: true, list: [{ id: 'e01' }] })
|
|
72
|
+
const out = await proxyAction('list').call(rec.ctx, { ent: 'x/y' })
|
|
73
|
+
assert.deepStrictEqual(out, { ok: true, list: [{ id: 'e01' }] })
|
|
74
|
+
|
|
75
|
+
const failed = recorder({ ok: false, why: 'forbidden' })
|
|
76
|
+
const bad = await proxyAction('load').call(failed.ctx, { ent: 'x/y', id: 'e01' })
|
|
77
|
+
assert.deepStrictEqual(bad, { ok: false, why: 'forbidden' })
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
test('proxies enforce the same rules, not weaker ones', async () => {
|
|
82
|
+
const seneca = await makeSeneca()
|
|
83
|
+
|
|
84
|
+
// Unauthenticated: refused, exactly like the real commands.
|
|
85
|
+
for (const cmd of OPS) {
|
|
86
|
+
const out = await seneca.post({
|
|
87
|
+
aim: 'web', on: 'ent', cmd, ent: 'sys/user', id: 'x', item: {},
|
|
88
|
+
})
|
|
89
|
+
assert.strictEqual(out.ok, false, cmd)
|
|
90
|
+
assert.strictEqual(out.why, 'not-authenticated', cmd)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The sys zone stays unreachable through the proxy.
|
|
94
|
+
const sys = await as(seneca, ALICE,
|
|
95
|
+
{ aim: 'web', on: 'ent', cmd: 'list', ent: 'sys/apikey' })
|
|
96
|
+
assert.strictEqual(sys.ok, false)
|
|
97
|
+
assert.strictEqual(sys.why, 'unknown-entity')
|
|
98
|
+
|
|
99
|
+
// sys/user remains read-only through it too.
|
|
100
|
+
const write = await as(seneca, ALICE, {
|
|
101
|
+
aim: 'web', on: 'ent', cmd: 'save', ent: 'sys/user', item: { name: 'Hacked' },
|
|
102
|
+
})
|
|
103
|
+
assert.strictEqual(write.ok, false)
|
|
104
|
+
assert.strictEqual(write.why, 'read-only')
|
|
105
|
+
|
|
106
|
+
await seneca.close()
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Explanation: web app architecture
|
|
2
|
+
|
|
3
|
+
*Diátaxis: explanation — why the generated app is built this way.*
|
|
4
|
+
|
|
5
|
+
## One primitive: Seneca messages
|
|
6
|
+
|
|
7
|
+
The frontend is web components on a browser Seneca bus. Every data flow —
|
|
8
|
+
backend CRUD, auth, cross-component events, cache reactivity — is a
|
|
9
|
+
pattern-matched message. `aim:*` messages travel transparently to the
|
|
10
|
+
backend gateway (the same patterns the backend services answer); local
|
|
11
|
+
concerns stay local. One primitive means one debugging story (the
|
|
12
|
+
in-window devtools show every flow), one interception point (the store
|
|
13
|
+
cache), and no parallel event system to keep consistent.
|
|
14
|
+
|
|
15
|
+
## Model-driven at runtime
|
|
16
|
+
|
|
17
|
+
The app fetches `/model.json` and derives its UI from it: the entity
|
|
18
|
+
menu, list columns, forms, relationship pickers and drill-down (from
|
|
19
|
+
`ref` fields), project scoping. Adding an entity to the model changes the
|
|
20
|
+
running UI on reload — no regeneration, no per-entity components, no
|
|
21
|
+
merge conflicts. This is what lets the same generated codebase serve an
|
|
22
|
+
entity graph of hundreds of entities.
|
|
23
|
+
|
|
24
|
+
## Access is membership, not ownership
|
|
25
|
+
|
|
26
|
+
Signed-in users collaborate through projects: membership in a project
|
|
27
|
+
grants access to the project's data (any entity carrying a
|
|
28
|
+
`ref: 'proj/project'` field is scoped to the selected project). The
|
|
29
|
+
generic backend `ent` service enforces this on every message — the
|
|
30
|
+
frontend only *reflects* scoping (the project selector), it never
|
|
31
|
+
enforces it.
|
|
32
|
+
|
|
33
|
+
## Generated once, then yours
|
|
34
|
+
|
|
35
|
+
The SPA is a starting point, not a framework artifact: after generation
|
|
36
|
+
the project owns it. Customisation is layered so upgrades stay cheap —
|
|
37
|
+
theme tokens (model) → CSS overrides (`custom.css`) → hooks
|
|
38
|
+
(`customise.js`) → custom entity views (model-declared) → direct edits
|
|
39
|
+
(last resort). Only `views.js` and `theme.css` regenerate, because they
|
|
40
|
+
are pure functions of the model.
|
|
41
|
+
|
|
42
|
+
## No framework, deliberately
|
|
43
|
+
|
|
44
|
+
Vanilla custom elements + `innerHTML` rendering keep the generated code
|
|
45
|
+
dependency-light, readable, and long-lived (no framework version
|
|
46
|
+
treadmill in generated projects). The cost — manual re-render discipline —
|
|
47
|
+
is contained by two conventions: explicit `reload()` calls after
|
|
48
|
+
mutations, and a render token to discard stale async renders (see
|
|
49
|
+
`cmp/admin.js`).
|
|
50
|
+
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# How to add a custom entity view
|
|
2
|
+
|
|
3
|
+
*Diátaxis: how-to guide — replace the generic admin UI for one entity
|
|
4
|
+
with a hand-coded component, declared in the model.*
|
|
5
|
+
|
|
6
|
+
## 1. Declare it in the model
|
|
7
|
+
|
|
8
|
+
In `backend/model/ent.aontu`, mark the entity:
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
shop: product: ux: { view: 'custom' }
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## 2. Generate the starter
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
cd backend && npm run model-build
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
This creates `web/src/cmp/view/shop_product.js` (a working starter
|
|
21
|
+
component — create-once, so your edits survive every future build) and
|
|
22
|
+
regenerates `web/src/views.js`, the index that imports all custom views.
|
|
23
|
+
The shell now mounts `<vg-view-shop-product>` instead of the generic
|
|
24
|
+
`<vg-entity-admin>` for that entity.
|
|
25
|
+
|
|
26
|
+
## 3. Implement the view
|
|
27
|
+
|
|
28
|
+
The component contract (same as the generic admin):
|
|
29
|
+
|
|
30
|
+
- **Properties** (set by the shell before `reload()`):
|
|
31
|
+
- `canon` — the entity canon, e.g. `'shop/product'`
|
|
32
|
+
- `projectId` — current project id when the entity is project-scoped,
|
|
33
|
+
else `null`
|
|
34
|
+
- `detailId` — an entity id to open in detail, or `null`
|
|
35
|
+
- `onNavigate(canon, id)` — call to navigate elsewhere in the app
|
|
36
|
+
- **Method**: `reload()` — re-fetch and re-render.
|
|
37
|
+
|
|
38
|
+
Use the same building blocks the generic admin uses:
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
import * as Model from '../../model.js' // labels, fields, refs graph
|
|
42
|
+
import * as Api from '../../api.js' // Api.list/load/save/remove(canon, ...)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Data via `Api` goes through the generic `aim:ent` messages, so
|
|
46
|
+
membership scoping and validation apply unchanged.
|
|
47
|
+
|
|
48
|
+
## Reverting
|
|
49
|
+
|
|
50
|
+
Remove the `ux` declaration and run `model-build` — `views.js` drops the
|
|
51
|
+
import and the entity falls back to the generic admin. Your component
|
|
52
|
+
file stays (delete it manually if unwanted).
|
|
53
|
+
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# How to change the theme
|
|
2
|
+
|
|
3
|
+
*Diátaxis: how-to guide — the design theme is part of the model; modes
|
|
4
|
+
and tokens flow from there into generated CSS.*
|
|
5
|
+
|
|
6
|
+
## Change a token
|
|
7
|
+
|
|
8
|
+
Edit `backend/model/theme.aontu` — each named mode (light, dark, ...) is
|
|
9
|
+
a set of design tokens:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
modes: {
|
|
13
|
+
light: {
|
|
14
|
+
primary: '#7c3aed'
|
|
15
|
+
# ...
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Then regenerate:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
cd backend && npm run model-build
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`web/src/theme.css` is rewritten: every token becomes a CSS variable
|
|
27
|
+
(`--vg-primary`, ...) scoped to its mode
|
|
28
|
+
(`:root[data-theme-mode="light"]`; the default mode also on `:root`).
|
|
29
|
+
Do not edit `theme.css` by hand — it regenerates from the model.
|
|
30
|
+
|
|
31
|
+
## Add a mode
|
|
32
|
+
|
|
33
|
+
Add a new block under `modes:` (e.g. `sepia: { ... }`) with the same
|
|
34
|
+
token set, and set `mode:` if it should be the default. After
|
|
35
|
+
`model-build`, the shell's user menu automatically offers the new mode
|
|
36
|
+
(the toggle appears whenever more than one mode exists), and the user's
|
|
37
|
+
choice persists (localStorage `vg-theme-mode`).
|
|
38
|
+
|
|
39
|
+
## Override per project without touching the model
|
|
40
|
+
|
|
41
|
+
`web/src/custom.css` loads after the theme:
|
|
42
|
+
|
|
43
|
+
```css
|
|
44
|
+
:root { --vg-radius: 10px; } /* all modes */
|
|
45
|
+
:root[data-theme-mode="dark"] { --vg-bg: #000; } /* one mode */
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Add modes or intercept the mode list at runtime
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
// web/src/customise.js
|
|
52
|
+
import * as Hooks from './hooks.js'
|
|
53
|
+
Hooks.addFilter('theme:modes', (modes) => [...modes, 'sepia'])
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
(Provide the CSS for a runtime-added mode yourself in `custom.css`.)
|
|
57
|
+
|
|
58
|
+
## Standard tokens
|
|
59
|
+
|
|
60
|
+
`primary`, `primary-dark`, `bg`, `surface`, `text`, `muted`, `border`,
|
|
61
|
+
`topbar-bg`, `topbar-fg`, `accent-bg`, `font`, `radius`, `shadow-card`.
|
|
62
|
+
Any additional token you add becomes `--vg-<token>` too.
|
|
63
|
+
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# How to customise the web app
|
|
2
|
+
|
|
3
|
+
*Diátaxis: how-to guide — change the generated app's look and behaviour
|
|
4
|
+
without editing generated components.*
|
|
5
|
+
|
|
6
|
+
Customisations live in two create-once files that survive regeneration:
|
|
7
|
+
`web/src/customise.js` (hook registrations) and `web/src/custom.css`
|
|
8
|
+
(style overrides).
|
|
9
|
+
|
|
10
|
+
## Inject or change HTML
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
// web/src/customise.js
|
|
14
|
+
import * as Hooks from './hooks.js'
|
|
15
|
+
|
|
16
|
+
Hooks.addHtml('shell:topbar:right', () => '<span class="vg-badge">Beta</span>')
|
|
17
|
+
Hooks.addHtml('admin:row:actions', ({ canon }) =>
|
|
18
|
+
'todo/item' === canon ? '<button class="vg-star" data-act="star">star</button>' : '')
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Transform data
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
// Sort or filter list rows before render:
|
|
25
|
+
Hooks.addFilter('admin:list:items', (items, { canon }) =>
|
|
26
|
+
'todo/item' === canon
|
|
27
|
+
? items.slice().sort((a, b) => (a.done === b.done ? 0 : a.done ? 1 : -1))
|
|
28
|
+
: items)
|
|
29
|
+
|
|
30
|
+
// Adjust the save payload:
|
|
31
|
+
Hooks.addFilter('admin:save:data', (data) => ({ ...data, source: 'web' }))
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Run behaviour at lifecycle moments
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
Hooks.addAction('admin:list:after', ({ root, canon, items }) => {
|
|
38
|
+
// wire events on injected elements, integrate analytics, etc.
|
|
39
|
+
})
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
All hooks are synchronous and failure-isolated (a throwing hook is logged
|
|
43
|
+
and skipped). The full list of points is in the
|
|
44
|
+
[web app reference](../reference/web-app.md#hook-points).
|
|
45
|
+
|
|
46
|
+
## Override styles
|
|
47
|
+
|
|
48
|
+
Generated styles use CSS variables from the theme. Override tokens or add
|
|
49
|
+
rules in `web/src/custom.css`:
|
|
50
|
+
|
|
51
|
+
```css
|
|
52
|
+
:root { --vg-primary: #7c3aed; }
|
|
53
|
+
.vg-badge { background: gold; color: #333; }
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
For theme-wide changes (all modes, or new modes), change the model
|
|
57
|
+
instead — see [Change the theme](change-the-theme.md).
|
|
58
|
+
|
|
59
|
+
## When hooks aren't enough
|
|
60
|
+
|
|
61
|
+
- Replace an entity's whole view:
|
|
62
|
+
[Add a custom entity view](add-a-custom-entity-view.md).
|
|
63
|
+
- Backend behaviour: override Seneca actions with priors in the backend
|
|
64
|
+
services.
|
|
65
|
+
- As a last resort, edit the generated components directly — they are
|
|
66
|
+
create-once and yours; just note that framework updates then need a
|
|
67
|
+
manual merge.
|
|
68
|
+
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# How to use the REST API
|
|
2
|
+
|
|
3
|
+
*Diátaxis: how-to guide — the strict-JSON REST API generated from the
|
|
4
|
+
model (`main.api` in `backend/model/api.aontu`).*
|
|
5
|
+
|
|
6
|
+
## Get an API key
|
|
7
|
+
|
|
8
|
+
Sign in to the web app → user menu → **Settings & security** → **API
|
|
9
|
+
keys** → create a key. Copy it immediately — it is shown only once (only
|
|
10
|
+
a hash is stored). Revoke keys there too; revocation is immediate.
|
|
11
|
+
|
|
12
|
+
## Call the API
|
|
13
|
+
|
|
14
|
+
Uniform semantic paths per entity — `<prefix>/<version>/<zone>/<name>`:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
KEY=vk_...
|
|
18
|
+
B=http://localhost:8080/api/v1
|
|
19
|
+
|
|
20
|
+
curl -H "Authorization: Bearer $KEY" $B/proj/project # list
|
|
21
|
+
curl -H "Authorization: Bearer $KEY" "$B/proj/project?name=X" # filtered list
|
|
22
|
+
curl -H "Authorization: Bearer $KEY" $B/proj/project/<id> # load
|
|
23
|
+
curl -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
|
|
24
|
+
-d '{"name":"New project"}' $B/proj/project # create -> 201
|
|
25
|
+
curl -X PUT -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
|
|
26
|
+
-d '{"note":"updated"}' $B/proj/project/<id> # partial update
|
|
27
|
+
curl -X DELETE -H "Authorization: Bearer $KEY" $B/proj/project/<id>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Results: `{ items: [...] }` (list), `{ item: {...} }` (load/create/update),
|
|
31
|
+
`{ ok: true, id }` (delete). Errors carry an HTTP status and
|
|
32
|
+
`{ error: { code, message?, details? } }`.
|
|
33
|
+
|
|
34
|
+
The API is **strict**: request bodies are validated against the entity's
|
|
35
|
+
model definition (generated shapes) — unknown fields are rejected (400),
|
|
36
|
+
required fields enforced, server-managed fields (`id`, `owner_id`,
|
|
37
|
+
`t_c`, `t_m`) are read-only. Access is the same project-membership
|
|
38
|
+
scoping as the web app, as the key's owning user.
|
|
39
|
+
|
|
40
|
+
## The OpenAPI spec
|
|
41
|
+
|
|
42
|
+
Generated from the model on every model-build:
|
|
43
|
+
`backend/gen/api/openapi.json`, also served unauthenticated at
|
|
44
|
+
`<prefix>/openapi.json`. Schemas come from the entity field definitions;
|
|
45
|
+
operation ids are uniform (`list_<zone>_<name>`, `create_...`, ...) so an
|
|
46
|
+
SDK can be generated from it (e.g. with sdkgen).
|
|
47
|
+
|
|
48
|
+
## Configure
|
|
49
|
+
|
|
50
|
+
`backend/model/api.aontu` — `prefix`, `version`, `active`, and per-entity
|
|
51
|
+
exposure (`ent: 'zone/name': { active: false }`). The sys zone is never
|
|
52
|
+
exposed. Run `npm run model-build` after changes.
|
|
53
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Reference: the web app
|
|
2
|
+
|
|
3
|
+
*Diátaxis: reference — the generated SPA's structure, messages, hook
|
|
4
|
+
points, and theme contract.*
|
|
5
|
+
|
|
6
|
+
## Files (web/src/)
|
|
7
|
+
|
|
8
|
+
| File | Role | Regenerated? |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| `main.js` | Entry: imports css, bus, components, views, customise | no |
|
|
11
|
+
| `bus.js` | The browser Seneca bus (transport to the backend gateway) | no |
|
|
12
|
+
| `model.js` | Runtime model: fetches `/model.json`; entities, refs graph, labels, project scoping, custom-view tags | no |
|
|
13
|
+
| `api.js` | Client for `aim:ent` CRUD + `aim:auth` | no |
|
|
14
|
+
| `hooks.js` | Customisation hook registry | no |
|
|
15
|
+
| `theme.js` | Theme mode controller (persisted; applies `data-theme-mode`) | no |
|
|
16
|
+
| `style.css` | App styles (consume `--vg-*` variables) | no |
|
|
17
|
+
| `theme.css` | CSS variables per theme mode | **yes — from the model** |
|
|
18
|
+
| `views.js` | Index importing custom entity views | **yes — from the model** |
|
|
19
|
+
| `customise.js` / `custom.css` | Project customisations | no (yours) |
|
|
20
|
+
| `cmp/app.js` | Router: signed-out → public, signed-in → shell | no |
|
|
21
|
+
| `cmp/public.js` | Public site + login mount | no |
|
|
22
|
+
| `cmp/auth.js` | Login / forgot-password form | no |
|
|
23
|
+
| `cmp/shell.js` | App shell: topbar, project selector, user menu, entity nav | no |
|
|
24
|
+
| `cmp/admin.js` | Generic entity admin (list/detail/form, relationship drill) | no |
|
|
25
|
+
| `cmp/settings.js` | Profile + change password | no |
|
|
26
|
+
| `cmp/view/*.js` | Custom entity views (one per `ux:{view:'custom'}` entity) | no (starters) |
|
|
27
|
+
|
|
28
|
+
## Backend messages used
|
|
29
|
+
|
|
30
|
+
**The browser may only send `aim:web` messages.** The gateway allow-list
|
|
31
|
+
names that one namespace, and every message the SPA sends is declared in
|
|
32
|
+
the model as an `aim:web` PROXY that forwards to the real service message
|
|
33
|
+
(`web_*` action files). Service namespaces (`aim:auth`, `aim:ent`,
|
|
34
|
+
`aim:api`, ...) are internal: posting one from a browser is rejected with
|
|
35
|
+
`not-allowed`. API-key clients have the same shape - the REST router
|
|
36
|
+
posts `aim:api`, which is likewise not browser-reachable.
|
|
37
|
+
|
|
38
|
+
| Browser message | Proxies to | Purpose |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `aim:web,on:ent,cmd:list,ent:<canon>` (+`q`) | `aim:ent,cmd:list` | List (membership/project-scoped) |
|
|
41
|
+
| `aim:web,on:ent,cmd:load,ent:<canon>` (+`id`) | `aim:ent,cmd:load` | Load one |
|
|
42
|
+
| `aim:web,on:ent,cmd:save,ent:<canon>` (+`item`) | `aim:ent,cmd:save` | Create/update |
|
|
43
|
+
| `aim:web,on:ent,cmd:remove,ent:<canon>` (+`id`) | `aim:ent,cmd:remove` | Delete |
|
|
44
|
+
| `aim:web,on:auth,load:auth` | `aim:auth,load:auth` | Current principal |
|
|
45
|
+
| `aim:web,on:auth,signin:user` / `signout:user` | `aim:auth,*` | Session (sets/clears the cookie) |
|
|
46
|
+
| `aim:web,on:auth,change:pass` / `update:user` | `aim:auth,*` | Settings & security |
|
|
47
|
+
| `aim:web,on:auth,remind:pass` | `aim:auth,remind:pass` | Password reminder (server stub) |
|
|
48
|
+
| `aim:web,on:auth,create:apikey` / `list:apikey` / `revoke:apikey` | `aim:auth,*` | API keys |
|
|
49
|
+
|
|
50
|
+
To expose a new operation to the browser, declare an `aim:web` proxy for
|
|
51
|
+
it in the model and implement the `web_*` action - never widen the
|
|
52
|
+
allow-list.
|
|
53
|
+
|
|
54
|
+
## Hook points
|
|
55
|
+
|
|
56
|
+
Register in `customise.js`; kinds: `html` (inject markup), `filter`
|
|
57
|
+
(transform a value), `action` (side effect). All synchronous,
|
|
58
|
+
failure-isolated.
|
|
59
|
+
|
|
60
|
+
| Point | Kind | Context / value |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `shell:topbar:right` | html | `{ user }` |
|
|
63
|
+
| `shell:sidebar:top` | html | `{ user }` |
|
|
64
|
+
| `shell:nav:items` | filter | entity list for the nav menu |
|
|
65
|
+
| `admin:list:toolbar` | html | list toolbar region |
|
|
66
|
+
| `admin:list:items` | filter | rows before render |
|
|
67
|
+
| `admin:list:columns` | filter | column defs |
|
|
68
|
+
| `admin:row:actions` | html | per-row action cell |
|
|
69
|
+
| `admin:list:after` | action | `{ root, canon, items }` |
|
|
70
|
+
| `admin:form:fields` | filter | form field defs |
|
|
71
|
+
| `admin:form:extra` | html | extra form markup |
|
|
72
|
+
| `admin:form:after` | action | after form render |
|
|
73
|
+
| `admin:save:data` | filter | payload before save |
|
|
74
|
+
| `admin:save:after` | action | after successful save |
|
|
75
|
+
| `public:sections` | html | extra public-site sections |
|
|
76
|
+
| `auth:form:footer` | html | login form footer |
|
|
77
|
+
| `settings:sections` | html | extra settings sections |
|
|
78
|
+
| `theme:modes` | filter | available theme mode list |
|
|
79
|
+
|
|
80
|
+
## Theme contract
|
|
81
|
+
|
|
82
|
+
- Model: `main.theme` = `{ mode, modes: { <name>: { <token>: <value> } } }`
|
|
83
|
+
(source: `backend/model/theme.aontu`).
|
|
84
|
+
- Generated CSS: each token → `--vg-<token>` under
|
|
85
|
+
`:root[data-theme-mode="<name>"]`; default mode also on `:root`.
|
|
86
|
+
- Runtime: `theme.js` sets `data-theme-mode` on `<html>`; choice persists
|
|
87
|
+
in localStorage `vg-theme-mode`; shell user menu toggles when more than
|
|
88
|
+
one mode exists.
|
|
89
|
+
- Standard tokens: `primary`, `primary-dark`, `bg`, `surface`, `text`,
|
|
90
|
+
`muted`, `border`, `topbar-bg`, `topbar-fg`, `accent-bg`, `font`,
|
|
91
|
+
`radius`, `shadow-card`.
|
|
92
|
+
|
|
93
|
+
## Custom entity view contract
|
|
94
|
+
|
|
95
|
+
Tag `vg-view-<zone>-<name>`; properties `canon`, `projectId`, `detailId`,
|
|
96
|
+
`onNavigate(canon, id)`; method `reload()`. Declared by
|
|
97
|
+
`ux: { view: 'custom' }` on the model entity.
|
|
98
|
+
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Agent guide: $$name$$ web app
|
|
2
|
+
|
|
3
|
+
The model-driven SPA for $$name$$: vanilla web components on a browser
|
|
4
|
+
Seneca message bus (no framework). Generated once by @voxgig/build EnvWeb;
|
|
5
|
+
now developer-owned. Backend guide: ../AGENTS.md. Concept docs:
|
|
6
|
+
../docs/ (see reference/web-app.md and explanation/web-architecture.md).
|
|
7
|
+
|
|
8
|
+
## Commands (run in web/)
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install
|
|
12
|
+
npm run dev # vite dev server (backend must be running: npm run web in backend/)
|
|
13
|
+
npm run e2e # playwright e2e (headless; starts its own backend)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
E2e tests run headless Chromium with workers=1 against a shared backend —
|
|
17
|
+
keep new specs independent of execution order but tolerant of shared seed
|
|
18
|
+
data.
|
|
19
|
+
|
|
20
|
+
## Architecture in one paragraph
|
|
21
|
+
|
|
22
|
+
`main.js` imports theme/css, the bus, all components, `views.js`
|
|
23
|
+
(generated index of custom views), and `customise.js` (project hooks).
|
|
24
|
+
All data flows are Seneca messages on `bus.js`. **Only `aim:web`
|
|
25
|
+
messages leave the browser** - that is the sole namespace the gateway
|
|
26
|
+
accepts, and each one is a declared proxy to a real service message;
|
|
27
|
+
`cmp:*` and events stay local. The transport pin is `aim:web` too, so a
|
|
28
|
+
stray service-message post fails locally instead of travelling. The UI is
|
|
29
|
+
model-driven at runtime — `model.js` fetches `/model.json` and derives
|
|
30
|
+
the entity menu, forms, and relationship navigation; `api.js` wraps the
|
|
31
|
+
`aim:web,on:ent,cmd:*` CRUD proxies and the `aim:web,on:auth,*` proxies.
|
|
32
|
+
|
|
33
|
+
## Editing rules
|
|
34
|
+
|
|
35
|
+
- Everything here is create-once EXCEPT `src/views.js` and
|
|
36
|
+
`src/theme.css` — those are REGENERATED from the model
|
|
37
|
+
(`npm run model-build` in backend/); never hand-edit them. Theme tokens
|
|
38
|
+
live in `backend/model/theme.aontu`; per-project overrides in
|
|
39
|
+
`src/custom.css`.
|
|
40
|
+
- Prefer customisation over editing generated components: hooks in
|
|
41
|
+
`src/customise.js` (see `src/hooks.js` for points), CSS variables in
|
|
42
|
+
`src/custom.css`, or a custom entity view (`ux:{view:'custom'}` in the
|
|
43
|
+
model). Edit components directly only when those layers can't express
|
|
44
|
+
the change.
|
|
45
|
+
- Components re-render by setting `innerHTML`; async renders must guard
|
|
46
|
+
against stale overwrites (see the render-token pattern in
|
|
47
|
+
`cmp/admin.js`: capture a token at method start, bail before writing if
|
|
48
|
+
superseded).
|
|
49
|
+
- `bus.sub` has no auto-unsubscribe: long-lived subscriptions in
|
|
50
|
+
components must guard callbacks with `this.isConnected`.
|
|
51
|
+
- Escape all user data with the local `esc()` helpers when building HTML.
|
|
52
|
+
|
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
import { test, expect } from '@playwright/test'
|
|
2
2
|
|
|
3
|
-
// Generic smoke test: the
|
|
4
|
-
// model-driven
|
|
5
|
-
//
|
|
3
|
+
// Generic smoke test: the public site loads, a seeded user can sign in, and
|
|
4
|
+
// the model-driven app shell renders with an entity menu. Add project-specific
|
|
5
|
+
// flows (CRUD, relationship drill, settings) in your own spec files.
|
|
6
6
|
|
|
7
|
-
test('sign in and
|
|
7
|
+
test('public site, sign in, and the app shell', async ({ page }) => {
|
|
8
8
|
await page.goto('/')
|
|
9
9
|
|
|
10
|
+
// Public content site with a login form.
|
|
10
11
|
await expect(page.locator('vg-auth h2')).toHaveText('Sign in')
|
|
11
12
|
|
|
12
|
-
await page.fill('input[name=email]', '$$seedEmail$$')
|
|
13
|
-
await page.fill('input[name=password]', '$$seedPassword$$')
|
|
13
|
+
await page.fill('vg-auth input[name=email]', '$$seedEmail$$')
|
|
14
|
+
await page.fill('vg-auth input[name=password]', '$$seedPassword$$')
|
|
14
15
|
await page.click('vg-auth button[type=submit]')
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
await expect(page.locator('
|
|
17
|
+
// The enterprise shell: top bar with the signed-in user, and a
|
|
18
|
+
// model-driven entity menu with at least one entity.
|
|
19
|
+
await expect(page.locator('.vg-shell')).toBeVisible()
|
|
20
|
+
await expect(page.locator('.vg-user-btn')).toContainText('$$seedEmail$$')
|
|
21
|
+
await expect(page.locator('.vg-navlink')).not.toHaveCount(0)
|
|
21
22
|
})
|
|
23
|
+
|
|
@@ -30,7 +30,7 @@ export default defineConfig({
|
|
|
30
30
|
},
|
|
31
31
|
],
|
|
32
32
|
webServer: {
|
|
33
|
-
command: `npm run build && cd ../backend && PORT=${PORT} node dist/env/web/web.js`,
|
|
33
|
+
command: `npm run build && cd ../backend && PORT=${PORT} REPL=false node dist/env/web/web.js`,
|
|
34
34
|
url: `http://localhost:${PORT}/`,
|
|
35
35
|
timeout: 60000,
|
|
36
36
|
reuseExistingServer: false,
|
|
@@ -38,3 +38,4 @@ export default defineConfig({
|
|
|
38
38
|
stderr: 'pipe',
|
|
39
39
|
},
|
|
40
40
|
})
|
|
41
|
+
|