@voxgig/build 4.4.0 → 4.12.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.
Files changed (100) hide show
  1. package/README.md +51 -2
  2. package/build.ts +20 -0
  3. package/dist/api/api_gen.d.ts +7 -0
  4. package/dist/api/api_gen.js +370 -0
  5. package/dist/api/api_gen.js.map +1 -0
  6. package/dist/build.d.ts +16 -1
  7. package/dist/build.js +17 -1
  8. package/dist/build.js.map +1 -1
  9. package/dist/doc/doc_gen.d.ts +8 -0
  10. package/dist/doc/doc_gen.js +397 -0
  11. package/dist/doc/doc_gen.js.map +1 -0
  12. package/dist/env/web/web_gen.js +197 -11
  13. package/dist/env/web/web_gen.js.map +1 -1
  14. package/dist/shape/ent.js +4 -2
  15. package/dist/shape/ent.js.map +1 -1
  16. package/env/web/web_gen.ts +219 -13
  17. package/package.json +6 -9
  18. package/tm/web/backend/env/shared/seed.ts.frag +23 -0
  19. package/tm/web/backend/env/web/api.ts.frag +157 -0
  20. package/tm/web/backend/env/web/web.ts.frag +43 -8
  21. package/tm/web/backend/srv/api/api-srv.ts.frag +4 -0
  22. package/tm/web/backend/srv/api/expose.ts.frag +33 -0
  23. package/tm/web/backend/srv/api/get_info.ts.frag +6 -0
  24. package/tm/web/backend/srv/api/on_ent.ts.frag +124 -0
  25. package/tm/web/backend/srv/auth/apikey_util.ts.frag +10 -0
  26. package/tm/web/backend/srv/auth/auth-srv.ts.frag +1 -0
  27. package/tm/web/backend/srv/auth/change_pass.ts.frag +12 -0
  28. package/tm/web/backend/srv/auth/create_apikey.ts.frag +33 -0
  29. package/tm/web/backend/srv/auth/get_info.ts.frag +1 -0
  30. package/tm/web/backend/srv/auth/list_apikey.ts.frag +15 -0
  31. package/tm/web/backend/srv/auth/load_auth.ts.frag +10 -1
  32. package/tm/web/backend/srv/auth/remind_pass.ts.frag +35 -0
  33. package/tm/web/backend/srv/auth/revoke_apikey.ts.frag +23 -0
  34. package/tm/web/backend/srv/auth/signin_user.ts.frag +12 -2
  35. package/tm/web/backend/srv/auth/signout_user.ts.frag +11 -1
  36. package/tm/web/backend/srv/auth/update_user.ts.frag +15 -0
  37. package/tm/web/backend/srv/auth/user_util.ts.frag +21 -0
  38. package/tm/web/backend/srv/auth/web_change_pass.ts.frag +16 -0
  39. package/tm/web/backend/srv/auth/web_create_apikey.ts.frag +15 -0
  40. package/tm/web/backend/srv/auth/web_list_apikey.ts.frag +11 -0
  41. package/tm/web/backend/srv/auth/web_load_auth.ts.frag +1 -0
  42. package/tm/web/backend/srv/auth/web_remind_pass.ts.frag +7 -0
  43. package/tm/web/backend/srv/auth/web_revoke_apikey.ts.frag +14 -0
  44. package/tm/web/backend/srv/auth/web_signin_user.ts.frag +1 -0
  45. package/tm/web/backend/srv/auth/web_signout_user.ts.frag +11 -3
  46. package/tm/web/backend/srv/auth/web_update_user.ts.frag +11 -0
  47. package/tm/web/backend/srv/ent/access.ts.frag +179 -0
  48. package/tm/web/backend/srv/ent/cmd_list.ts.frag +86 -0
  49. package/tm/web/backend/srv/ent/cmd_load.ts.frag +56 -0
  50. package/tm/web/backend/srv/ent/cmd_remove.ts.frag +60 -0
  51. package/tm/web/backend/srv/ent/cmd_save.ts.frag +141 -0
  52. package/tm/web/backend/srv/ent/ent-srv.ts.frag +4 -0
  53. package/tm/web/backend/srv/ent/get_info.ts.frag +6 -0
  54. package/tm/web/backend/srv/ent/web_cmd_list.ts.frag +17 -0
  55. package/tm/web/backend/srv/ent/web_cmd_load.ts.frag +17 -0
  56. package/tm/web/backend/srv/ent/web_cmd_remove.ts.frag +17 -0
  57. package/tm/web/backend/srv/ent/web_cmd_save.ts.frag +17 -0
  58. package/tm/web/backend/test/unit/env/web/api-router.test.ts.frag +315 -0
  59. package/tm/web/backend/test/unit/env/web/surface.test.ts.frag +109 -0
  60. package/tm/web/backend/test/unit/srv/api/api.setup.ts.frag +162 -0
  61. package/tm/web/backend/test/unit/srv/api/api.test.ts.frag +225 -0
  62. package/tm/web/backend/test/unit/srv/auth/apikey.test.ts.frag +152 -0
  63. package/tm/web/backend/test/unit/srv/auth/auth.setup.ts.frag +40 -0
  64. package/tm/web/backend/test/unit/srv/auth/session.test.ts.frag +189 -0
  65. package/tm/web/backend/test/unit/srv/ent/ent.setup.ts.frag +53 -0
  66. package/tm/web/backend/test/unit/srv/ent/proxy.test.ts.frag +109 -0
  67. package/tm/web/docs/explanation/web-architecture.md.frag +50 -0
  68. package/tm/web/docs/how-to/add-a-custom-entity-view.md.frag +53 -0
  69. package/tm/web/docs/how-to/change-the-theme.md.frag +63 -0
  70. package/tm/web/docs/how-to/customise-the-web-app.md.frag +68 -0
  71. package/tm/web/docs/how-to/use-the-api.md.frag +53 -0
  72. package/tm/web/docs/reference/web-app.md.frag +98 -0
  73. package/tm/web/web/AGENTS.md.frag +52 -0
  74. package/tm/web/web/e2e/smoke.spec.js.frag +13 -11
  75. package/tm/web/web/index.html.frag +1 -0
  76. package/tm/web/web/package.json.frag +1 -0
  77. package/tm/web/web/playwright.config.js.frag +2 -1
  78. package/tm/web/web/src/api.js.frag +92 -0
  79. package/tm/web/web/src/bus.js.frag +14 -8
  80. package/tm/web/web/src/cmp/admin.js.frag +293 -212
  81. package/tm/web/web/src/cmp/admin.md.frag +51 -0
  82. package/tm/web/web/src/cmp/app.js.frag +12 -15
  83. package/tm/web/web/src/cmp/app.md.frag +28 -0
  84. package/tm/web/web/src/cmp/auth.js.frag +69 -41
  85. package/tm/web/web/src/cmp/auth.md.frag +36 -0
  86. package/tm/web/web/src/cmp/public.js.frag +58 -0
  87. package/tm/web/web/src/cmp/public.md.frag +26 -0
  88. package/tm/web/web/src/cmp/settings.js.frag +178 -0
  89. package/tm/web/web/src/cmp/settings.md.frag +30 -0
  90. package/tm/web/web/src/cmp/shell.js.frag +231 -0
  91. package/tm/web/web/src/cmp/shell.md.frag +49 -0
  92. package/tm/web/web/src/cmp/view/custom-view.js.frag +47 -0
  93. package/tm/web/web/src/cmp/view/custom-view.md.frag +31 -0
  94. package/tm/web/web/src/custom.css.frag +8 -0
  95. package/tm/web/web/src/customise.js.frag +30 -0
  96. package/tm/web/web/src/hooks.js.frag +79 -0
  97. package/tm/web/web/src/main.js.frag +17 -4
  98. package/tm/web/web/src/model.js.frag +162 -0
  99. package/tm/web/web/src/style.css.frag +135 -21
  100. package/tm/web/web/src/theme.js.frag +82 -0
@@ -0,0 +1,189 @@
1
+ import { test, describe } from 'node:test'
2
+ import assert from 'node:assert'
3
+
4
+ import { makeSeneca, as } from './auth.setup'
5
+
6
+
7
+ // Sign-in / sign-out / session load, and the gateway wrappers that turn
8
+ // them into cookie operations for the SPA.
9
+ describe('auth session', () => {
10
+
11
+ async function withUser() {
12
+ const seneca = await makeSeneca()
13
+ const reg = await seneca.post('sys:user,register:user',
14
+ { name: 'Alice', email: 'alice@ex.com', password: 'alice-pass-01' })
15
+ assert.strictEqual(reg.ok, true)
16
+ return { seneca, user: reg.user }
17
+ }
18
+
19
+
20
+ test('signin succeeds with the right password and fails otherwise', async () => {
21
+ const { seneca } = await withUser()
22
+
23
+ const ok = await seneca.post('aim:auth,signin:user',
24
+ { email: 'alice@ex.com', password: 'alice-pass-01' })
25
+ assert.strictEqual(ok.ok, true)
26
+ assert.strictEqual(ok.user.email, 'alice@ex.com')
27
+
28
+ // aim:auth messages are gateway-reachable, so the result must never
29
+ // carry credentials: @seneca/user answers with the whole entity
30
+ // (password hash + salt) and its `fields` option does not stop it.
31
+ assert.strictEqual(ok.user.pass, undefined)
32
+ assert.strictEqual(ok.user.salt, undefined)
33
+ assert.deepStrictEqual(Object.keys(ok.user).sort(),
34
+ ['email', 'handle', 'id', 'name'])
35
+
36
+ const bad = await seneca.post('aim:auth,signin:user',
37
+ { email: 'alice@ex.com', password: 'wrong' })
38
+ assert.strictEqual(bad.ok, false)
39
+
40
+ await seneca.close()
41
+ })
42
+
43
+
44
+ test('the gateway wrapper returns a cookie token on success', async () => {
45
+ const { seneca } = await withUser()
46
+
47
+ const out = await seneca.post(
48
+ { aim: 'web', on: 'auth', signin: 'user', email: 'alice@ex.com', password: 'alice-pass-01' })
49
+ assert.strictEqual(out.ok, true)
50
+ // gateway-auth (express_cookie) sets the cookie from this field.
51
+ assert.ok(out.gateway$.auth.token)
52
+ assert.strictEqual(out.user.email, 'alice@ex.com')
53
+ assert.strictEqual(out.user.pass, undefined)
54
+
55
+ const bad = await seneca.post(
56
+ { aim: 'web', on: 'auth', signin: 'user', email: 'alice@ex.com', password: 'wrong' })
57
+ assert.strictEqual(bad.ok, false)
58
+ assert.strictEqual(bad.gateway$, undefined)
59
+ assert.ok(bad.why)
60
+
61
+ await seneca.close()
62
+ })
63
+
64
+
65
+ test('signout revokes the login server-side', async () => {
66
+ const { seneca, user } = await withUser()
67
+
68
+ const login = await seneca.post('aim:auth,signin:user',
69
+ { email: 'alice@ex.com', password: 'alice-pass-01' })
70
+ const token = login.login.token
71
+ assert.ok(token)
72
+
73
+ const before = await seneca.entity('sys/login').list$({ token })
74
+ assert.strictEqual(before[0].active, true)
75
+
76
+ const out = await seneca.post('aim:auth,signout:user',
77
+ { user_id: user.id, token })
78
+ assert.strictEqual(out.ok, true)
79
+
80
+ // The session must really be revoked: a token alone does not do it
81
+ // (@seneca/user answers no-user-query and leaves the row active), so
82
+ // the user id has to travel with it.
83
+ const after = await seneca.entity('sys/login').list$({ token })
84
+ assert.strictEqual(after[0].active, false)
85
+
86
+ await seneca.close()
87
+ })
88
+
89
+
90
+ test('the signout wrapper revokes the session and clears the cookie', async () => {
91
+ const { seneca, user } = await withUser()
92
+
93
+ const login = await seneca.post('aim:auth,signin:user',
94
+ { email: 'alice@ex.com', password: 'alice-pass-01' })
95
+ const token = login.login.token
96
+
97
+ // The gateway principal carries both the user and the login token.
98
+ const out = await seneca.post({
99
+ aim: 'web', on: 'auth', signout: 'user',
100
+ custom$: { principal: { user, token } },
101
+ })
102
+ assert.strictEqual(out.ok, true)
103
+ // Tells gateway-auth to clear the cookie.
104
+ assert.strictEqual(out.gateway$.auth.remove, true)
105
+
106
+ const after = await seneca.entity('sys/login').list$({ token })
107
+ assert.strictEqual(after[0].active, false)
108
+
109
+ // Signing out with no principal at all is still a clean no-op.
110
+ const anon = await seneca.post({ aim: 'web', on: 'auth', signout: 'user' })
111
+ assert.strictEqual(anon.ok, true)
112
+
113
+ await seneca.close()
114
+ })
115
+
116
+
117
+ test('load:auth reports the signed-in user, or nobody', async () => {
118
+ const { seneca, user } = await withUser()
119
+
120
+ const anon = await seneca.post({ aim: 'web', on: 'auth', load: 'auth' })
121
+ assert.strictEqual(anon.ok, true)
122
+ assert.strictEqual(anon.user, undefined)
123
+
124
+ const signed = await as(seneca, user, { aim: 'web', on: 'auth', load: 'auth' })
125
+ assert.strictEqual(signed.ok, true)
126
+ assert.strictEqual(signed.user.email, 'alice@ex.com')
127
+ assert.strictEqual(signed.user.pass, undefined)
128
+
129
+ // The service message behind it must not leak credentials either.
130
+ const direct = await seneca.post('aim:auth,load:auth', { user_id: user.id })
131
+ assert.strictEqual(direct.ok, true)
132
+ assert.strictEqual(direct.state, 'signedin')
133
+ assert.strictEqual(direct.user.pass, undefined)
134
+ assert.strictEqual(direct.user.salt, undefined)
135
+
136
+ const gone = await seneca.post('aim:auth,load:auth', { user_id: 'nope' })
137
+ assert.strictEqual(gone.ok, false)
138
+ assert.strictEqual(gone.state, 'signedout')
139
+
140
+ await seneca.close()
141
+ })
142
+
143
+
144
+ test('update:user changes the profile of the signed-in user only', async () => {
145
+ const { seneca, user } = await withUser()
146
+
147
+ const out = await as(seneca, user,
148
+ { aim: 'web', on: 'auth', update: 'user', data: { name: 'Alicia' } })
149
+ assert.strictEqual(out.ok, true)
150
+
151
+ const check = await seneca.post('sys:user,get:user', { email: 'alice@ex.com' })
152
+ assert.strictEqual(check.user.name, 'Alicia')
153
+
154
+ const anon = await seneca.post(
155
+ { aim: 'web', on: 'auth', update: 'user', data: { name: 'Mallory' } })
156
+ assert.strictEqual(anon.ok, false)
157
+ assert.strictEqual(anon.why, 'not-authenticated')
158
+
159
+ await seneca.close()
160
+ })
161
+
162
+
163
+ test('remind:pass never reveals whether the account exists', async () => {
164
+ const { seneca } = await withUser()
165
+
166
+ const known = await seneca.post(
167
+ { aim: 'web', on: 'auth', remind: 'pass', email: 'alice@ex.com' })
168
+ const unknown = await seneca.post(
169
+ { aim: 'web', on: 'auth', remind: 'pass', email: 'nobody@ex.com' })
170
+
171
+ // Same answer either way (no user enumeration), and nothing is sent.
172
+ assert.strictEqual(known.ok, true)
173
+ assert.strictEqual(unknown.ok, true)
174
+ assert.strictEqual(known.user, undefined)
175
+ assert.strictEqual(unknown.user, undefined)
176
+
177
+ await seneca.close()
178
+ })
179
+
180
+
181
+ test('get:info reports the service', async () => {
182
+ const seneca = await makeSeneca()
183
+ const out = await seneca.post('aim:auth,get:info')
184
+ assert.strictEqual(out.ok, true)
185
+ assert.strictEqual(out.srv, 'auth')
186
+ await seneca.close()
187
+ })
188
+ })
189
+
@@ -0,0 +1,53 @@
1
+
2
+ import Seneca from 'seneca'
3
+
4
+ import { entity } from '@voxgig/util'
5
+
6
+ import Model from '../../../../model/model.json'
7
+
8
+ const { base } = require('../../../../dist/env/shared/basic')
9
+
10
+
11
+ // Build a Seneca instance with the generic entity service loaded from the
12
+ // compiled dist/srv. In-memory entity store, no external deps.
13
+ //
14
+ // @seneca/owner is loaded with the SAME options as the running app (from
15
+ // `base`): access control is enforced in the entity layer, so a test
16
+ // harness without it would exercise a system that cannot deny anything.
17
+ async function makeSeneca() {
18
+ const seneca = Seneca({ legacy: false, timeout: 2222, debug: { undead: true } })
19
+ seneca.context.model = Model
20
+ seneca.context.env = 'test'
21
+
22
+ return seneca
23
+ .test()
24
+ .use('promisify')
25
+ .use('entity', {
26
+ strict: true,
27
+ ent: entity(Model),
28
+ })
29
+ .use('entity-util', {
30
+ when: { active: true },
31
+ })
32
+ .use('owner', base.options.owner)
33
+ .use('reload')
34
+ .use('../../../../dist/srv/ent/ent-srv')
35
+ .ready()
36
+ }
37
+
38
+
39
+ // Post a message AS a given user, injecting the gateway-style principal into
40
+ // meta.custom (mirrors what @seneca/gateway-auth attaches from the cookie).
41
+ function as(seneca: any, user: any, msg: any) {
42
+ return seneca.post(Object.assign({}, msg, {
43
+ custom$: { principal: { user } },
44
+ }))
45
+ }
46
+
47
+
48
+ export {
49
+ makeSeneca,
50
+ as,
51
+ Model,
52
+ }
53
+
@@ -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 `$$fe$$/src/cmp/view/shop_product.js` (a working starter
21
+ component — create-once, so your edits survive every future build) and
22
+ regenerates `$$fe$$/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
+ `$$fe$$/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
+ `$$fe$$/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
+ // $$fe$$/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
+ `$$fe$$/src/customise.js` (hook registrations) and `$$fe$$/src/custom.css`
8
+ (style overrides).
9
+
10
+ ## Inject or change HTML
11
+
12
+ ```js
13
+ // $$fe$$/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 `$$fe$$/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
+