@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.
- 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 +197 -11
- 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 +219 -13
- 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 +43 -8
- 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,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
|
+
|