@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voxgig/build",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.12.0",
|
|
4
4
|
"main": "dist/build.js",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"types": "dist/build.d.ts",
|
|
@@ -19,12 +19,12 @@
|
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
21
|
"model-s01": "voxgig-model test/s01/model/model.aontu",
|
|
22
|
-
"test": "
|
|
23
|
-
"test-some": "
|
|
24
|
-
"test-watch": "
|
|
22
|
+
"test": "node --enable-source-maps --experimental-test-coverage --test-coverage-exclude='dist-test/test/**' --test-coverage-lines=95 --test-coverage-functions=85 --test-coverage-branches=78 --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage.lcov --test \"dist-test/test/**/*.test.js\"",
|
|
23
|
+
"test-some": "node --enable-source-maps --test --test-name-pattern=\"$npm_config_pattern\" \"dist-test/test/**/*.test.js\"",
|
|
24
|
+
"test-watch": "node --enable-source-maps --test --watch \"dist-test/test/**/*.test.js\"",
|
|
25
25
|
"watch": "tsc -w -d",
|
|
26
|
-
"build": "tsc -d",
|
|
27
|
-
"clean": "rm -rf node_modules yarn.lock package-lock.json",
|
|
26
|
+
"build": "tsc -d && tsc -p test && cp -r test/fixture test/richmodel.js dist-test/test/",
|
|
27
|
+
"clean": "rm -rf dist dist-test node_modules yarn.lock package-lock.json",
|
|
28
28
|
"reset": "npm run clean && npm i && npm run build && npm test",
|
|
29
29
|
"repo-tag": "REPO_VERSION=`node -e \"console.log(require('./package').version)\"` && echo TAG: v$REPO_VERSION && git commit -a -m v$REPO_VERSION && git push && git tag v$REPO_VERSION && git push --tags;",
|
|
30
30
|
"repo-publish": "npm run clean && npm i && npm run repo-publish-quick",
|
|
@@ -53,14 +53,11 @@
|
|
|
53
53
|
"js-yaml": "^4.1.0"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
|
-
"@types/jest": "^29.5.12",
|
|
57
56
|
"@types/js-yaml": "^4.0.9",
|
|
58
57
|
"@types/node": "26.1.2",
|
|
59
58
|
"@voxgig/model": "^9.2.4",
|
|
60
59
|
"@voxgig/system": "^1.2.0",
|
|
61
60
|
"esbuild": "^0.23.0",
|
|
62
|
-
"jest": "^29.7.0",
|
|
63
|
-
"ts-jest": "^29.2.2",
|
|
64
61
|
"typescript": "^5.5.3"
|
|
65
62
|
}
|
|
66
63
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Demo/seed data for local development. The web runner already seeds the
|
|
2
|
+
// predefined users; add your DOMAIN seed data here (projects, lists, items,
|
|
3
|
+
// ...). Runs once per boot against the in-memory store. Create-once —
|
|
4
|
+
// customise freely.
|
|
5
|
+
|
|
6
|
+
export async function seedDemo(
|
|
7
|
+
seneca: any,
|
|
8
|
+
usersByEmail: Record<string, any>,
|
|
9
|
+
): Promise<void> {
|
|
10
|
+
// Nothing seeded by default. Example (uncomment and adapt to your model):
|
|
11
|
+
//
|
|
12
|
+
// const owner = Object.values(usersByEmail)[0]
|
|
13
|
+
// if (!owner) return
|
|
14
|
+
// const existing = await seneca.entity('proj/project').list$({})
|
|
15
|
+
// if (existing.length > 0) return
|
|
16
|
+
// const now = Date.now()
|
|
17
|
+
// const p = await seneca.entity('proj/project')
|
|
18
|
+
// .data$({ name: 'Example', owner_id: owner.id, t_c: now, t_m: now }).save$()
|
|
19
|
+
// await seneca.entity('proj/member')
|
|
20
|
+
// .data$({ project_id: p.id, user_id: owner.id, role: 'owner',
|
|
21
|
+
// owner_id: owner.id, t_c: now, t_m: now }).save$()
|
|
22
|
+
}
|
|
23
|
+
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// The strict-JSON REST API endpoint (model main.api). Maps
|
|
2
|
+
//
|
|
3
|
+
// GET <prefix>/<version>/<zone>/<name> -> list (query -> q)
|
|
4
|
+
// GET <prefix>/<version>/<zone>/<name>/<id> -> load
|
|
5
|
+
// POST <prefix>/<version>/<zone>/<name> -> create
|
|
6
|
+
// PUT <prefix>/<version>/<zone>/<name>/<id> -> update
|
|
7
|
+
// DELETE <prefix>/<version>/<zone>/<name>/<id> -> remove
|
|
8
|
+
//
|
|
9
|
+
// onto aim:api,on:ent messages (src/srv/api/on_ent.ts). Uniform semantic
|
|
10
|
+
// paths and result shapes so an SDK can be generated (sdkgen); the
|
|
11
|
+
// OpenAPI spec is generated from the model (gen/api/openapi.json).
|
|
12
|
+
//
|
|
13
|
+
// Authentication: API access keys - Authorization: Bearer vk_... - looked
|
|
14
|
+
// up by sha-256 hash against sys/apikey and resolved to the owning user,
|
|
15
|
+
// which becomes the request principal (so project-membership access
|
|
16
|
+
// applies exactly as in the web app).
|
|
17
|
+
|
|
18
|
+
import Crypto from 'node:crypto'
|
|
19
|
+
import Path from 'node:path'
|
|
20
|
+
|
|
21
|
+
// Result 'why' -> HTTP status.
|
|
22
|
+
const STATUS: Record<string, number> = {
|
|
23
|
+
'not-authenticated': 401,
|
|
24
|
+
'forbidden': 403,
|
|
25
|
+
'unknown-entity': 404,
|
|
26
|
+
'not-found': 404,
|
|
27
|
+
'unknown-op': 405,
|
|
28
|
+
'read-only': 405,
|
|
29
|
+
'invalid-data': 400,
|
|
30
|
+
'project-required': 400,
|
|
31
|
+
'invalid-args': 400,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function apiHandler(seneca: any, model: any) {
|
|
35
|
+
const api = (model.main && model.main.api) || {}
|
|
36
|
+
const version = api.version || 'v1'
|
|
37
|
+
|
|
38
|
+
return async function handle(req: any, res: any) {
|
|
39
|
+
res.type('application/json')
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
// The generated OpenAPI spec (unauthenticated; from gen/api/).
|
|
43
|
+
if ('GET' === req.method && '/openapi.json' === req.path) {
|
|
44
|
+
return res.sendFile(Path.join(
|
|
45
|
+
__dirname, '..', '..', '..', 'gen', 'api', 'openapi.json'))
|
|
46
|
+
}
|
|
47
|
+
// The same spec as YAML - the format most codegen/SDK tools want.
|
|
48
|
+
if ('GET' === req.method && '/openapi.yaml' === req.path) {
|
|
49
|
+
res.type('application/yaml')
|
|
50
|
+
return res.sendFile(Path.join(
|
|
51
|
+
__dirname, '..', '..', '..', 'gen', 'api', 'openapi.yaml'))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Path (relative to the mount prefix): /<version>/<zone>/<name>[/<id>]
|
|
55
|
+
const parts = String(req.path || '').split('/').filter((p: string) => '' !== p)
|
|
56
|
+
if (version !== parts[0] || parts.length < 3 || 4 < parts.length) {
|
|
57
|
+
return fail(res, 404, 'not-found', 'unknown route')
|
|
58
|
+
}
|
|
59
|
+
const ent = parts[1] + '/' + parts[2]
|
|
60
|
+
const id = parts[3]
|
|
61
|
+
|
|
62
|
+
const user = await authenticate(seneca, req)
|
|
63
|
+
if (null == user) {
|
|
64
|
+
return fail(res, 401, 'not-authenticated',
|
|
65
|
+
'provide a valid API key: Authorization: Bearer <key>')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const method = String(req.method || 'GET').toUpperCase()
|
|
69
|
+
let op: string | null = null
|
|
70
|
+
let body = undefined
|
|
71
|
+
|
|
72
|
+
if ('GET' === method) {
|
|
73
|
+
op = null == id ? 'list' : 'load'
|
|
74
|
+
}
|
|
75
|
+
else if ('POST' === method && null == id) {
|
|
76
|
+
op = 'create'
|
|
77
|
+
body = req.body
|
|
78
|
+
}
|
|
79
|
+
else if ('PUT' === method && null != id) {
|
|
80
|
+
op = 'update'
|
|
81
|
+
body = req.body
|
|
82
|
+
}
|
|
83
|
+
else if ('DELETE' === method && null != id) {
|
|
84
|
+
op = 'remove'
|
|
85
|
+
}
|
|
86
|
+
if (null == op) {
|
|
87
|
+
return fail(res, 405, 'unknown-op', 'method not supported on this path')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const out = await seneca.post('aim:api,on:ent', {
|
|
91
|
+
op,
|
|
92
|
+
ent,
|
|
93
|
+
id,
|
|
94
|
+
data: body,
|
|
95
|
+
q: 'list' === op ? Object.assign({}, req.query) : undefined,
|
|
96
|
+
custom$: { principal: { user } },
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
if (!out.ok) {
|
|
100
|
+
return fail(res, STATUS[out.why] || 500, out.why || 'failed',
|
|
101
|
+
out.message, out.details)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const status = 'create' === op ? 201 : 200
|
|
105
|
+
const result: any = {}
|
|
106
|
+
if (null != out.items) {
|
|
107
|
+
result.items = out.items
|
|
108
|
+
}
|
|
109
|
+
if (null != out.item) {
|
|
110
|
+
result.item = out.item
|
|
111
|
+
}
|
|
112
|
+
if ('remove' === op) {
|
|
113
|
+
result.ok = true
|
|
114
|
+
result.id = id
|
|
115
|
+
}
|
|
116
|
+
return res.status(status).send(result)
|
|
117
|
+
}
|
|
118
|
+
catch (e: any) {
|
|
119
|
+
return fail(res, 500, 'error', 'internal error')
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function fail(res: any, status: number, code: string, message?: string, details?: any) {
|
|
125
|
+
const error: any = { code }
|
|
126
|
+
if (message) {
|
|
127
|
+
error.message = message
|
|
128
|
+
}
|
|
129
|
+
if (details) {
|
|
130
|
+
error.details = details
|
|
131
|
+
}
|
|
132
|
+
return res.status(status).send({ error })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Resolve the Bearer API key to its owning user (the principal), or null.
|
|
136
|
+
async function authenticate(seneca: any, req: any): Promise<any> {
|
|
137
|
+
const header = String(req.headers['authorization'] || '')
|
|
138
|
+
const m = header.match(/^Bearer\s+(\S+)$/i)
|
|
139
|
+
if (null == m) {
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
const hash = Crypto.createHash('sha256').update(m[1]).digest('hex')
|
|
143
|
+
const keys = await seneca.entity('sys/apikey').list$({ hash })
|
|
144
|
+
const key = keys.find((k: any) => !k.revoked)
|
|
145
|
+
if (null == key) {
|
|
146
|
+
return null
|
|
147
|
+
}
|
|
148
|
+
const user = await seneca.entity('sys/user').load$(key.user_id)
|
|
149
|
+
if (null == user) {
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
const d = user.data$(false)
|
|
153
|
+
delete d.pass
|
|
154
|
+
delete d.salt
|
|
155
|
+
return d
|
|
156
|
+
}
|
|
157
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
|
|
2
2
|
// Local web runner: the local in-memory backend plus an HTTP layer for
|
|
3
|
-
// the SPA - express serving
|
|
3
|
+
// the SPA - express serving the frontend dist, the model, and a single seneca
|
|
4
4
|
// gateway endpoint (/seneca) that the browser-side Seneca bus posts
|
|
5
5
|
// messages to (seneca-browser fetch transport). Cookie auth via
|
|
6
6
|
// @seneca/gateway-auth (express_cookie): signin sets the todo-auth
|
|
@@ -18,6 +18,8 @@ import Seneca from 'seneca'
|
|
|
18
18
|
import { Local } from '@voxgig/system'
|
|
19
19
|
|
|
20
20
|
import { basic, base } from '../shared/basic'
|
|
21
|
+
import { seedDemo } from '../shared/seed'
|
|
22
|
+
import { apiHandler } from './api'
|
|
21
23
|
|
|
22
24
|
import Pkg from '../../../package.json'
|
|
23
25
|
import Model from '../../../model/model.json'
|
|
@@ -48,12 +50,25 @@ async function run() {
|
|
|
48
50
|
|
|
49
51
|
basic(seneca)
|
|
50
52
|
|
|
53
|
+
// Dev REPL (@seneca/repl): poke the running system with messages -
|
|
54
|
+
// npx seneca-repl telnet://localhost:<port.repl>
|
|
55
|
+
// Disable with REPL=false; override the port with REPL_PORT.
|
|
56
|
+
if ('false' !== process.env.REPL) {
|
|
57
|
+
seneca.use('repl', {
|
|
58
|
+
port: parseInt(process.env.REPL_PORT || '', 10) ||
|
|
59
|
+
(Model as any).main.conf.port.repl,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
seneca
|
|
52
64
|
.use('gateway', {
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
65
|
+
// THE BROWSER SURFACE. Only aim:web is reachable from a browser:
|
|
66
|
+
// every message the SPA may send is declared in the model as an
|
|
67
|
+
// aim:web PROXY that forwards to the real service message. Service
|
|
68
|
+
// namespaces (aim:auth, aim:ent, ...) stay internal, so a browser
|
|
69
|
+
// cannot post one directly. API-key clients have their own proxy
|
|
70
|
+
// layer (aim:api behind the REST router, see ./api.ts).
|
|
71
|
+
allow: { 'aim:web': true },
|
|
57
72
|
})
|
|
58
73
|
.use('gateway-express', {
|
|
59
74
|
// gateway-express sets/clears the auth cookie when a result carries
|
|
@@ -89,23 +104,42 @@ async function run() {
|
|
|
89
104
|
await seneca.ready()
|
|
90
105
|
|
|
91
106
|
// Seed the predefined users (idempotent per boot: in-memory store).
|
|
107
|
+
const usersByEmail: Record<string, any> = {}
|
|
92
108
|
for (const u of USERS) {
|
|
93
109
|
const res = await seneca.post('sys:user,register:user', u)
|
|
94
110
|
if (!res.ok) {
|
|
95
111
|
console.log('SEED-USER-FAILED', u.email, res.why)
|
|
96
112
|
}
|
|
113
|
+
const got = await seneca.post('sys:user,get:user', { email: u.email })
|
|
114
|
+
if (got.ok && got.user) {
|
|
115
|
+
usersByEmail[u.email] = got.user
|
|
116
|
+
}
|
|
97
117
|
}
|
|
98
118
|
|
|
119
|
+
// Seed demo projects/todolists/items (collaborative, one shared project).
|
|
120
|
+
await seedDemo(seneca, usersByEmail)
|
|
121
|
+
|
|
99
122
|
const app = Express()
|
|
100
|
-
//
|
|
101
|
-
// project root, then
|
|
123
|
+
// The frontend folder is a sibling of backend/; from dist/env/local go
|
|
124
|
+
// up to the project root, then into its dist. The folder name comes from
|
|
125
|
+
// the model (env web `dir`, default 'web'), so it stays in step if the
|
|
126
|
+
// project renames it.
|
|
102
127
|
const webdist = Path.join(
|
|
103
|
-
__dirname, '..', '..', '..', '..', '
|
|
128
|
+
__dirname, '..', '..', '..', '..', '$$fe$$', 'dist')
|
|
104
129
|
|
|
105
130
|
app
|
|
106
131
|
.use(Express.json())
|
|
107
132
|
.use(new (CookieParser as any)())
|
|
108
133
|
.post('/seneca', seneca.export('gateway-express/handler'))
|
|
134
|
+
|
|
135
|
+
// The strict-JSON REST API (model main.api), if active. Mounted with
|
|
136
|
+
// use() so req.path inside the handler is relative to the prefix.
|
|
137
|
+
const apiconf = (Model as any).main.api
|
|
138
|
+
if (apiconf && false !== apiconf.active) {
|
|
139
|
+
app.use(apiconf.prefix || '/api', apiHandler(seneca, Model))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
app
|
|
109
143
|
.get('/model.json',
|
|
110
144
|
(_req: any, res: any) => res.sendFile(
|
|
111
145
|
Path.join(__dirname, '..', '..', '..', 'model', 'model.json')))
|
|
@@ -118,3 +152,4 @@ async function run() {
|
|
|
118
152
|
version: Pkg.version,
|
|
119
153
|
})
|
|
120
154
|
}
|
|
155
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Which entities the REST API exposes (model main.api). Application
|
|
2
|
+
// entities are exposed by default; the sys zone never is; per-entity
|
|
3
|
+
// overrides live under main.api.ent.
|
|
4
|
+
export function exposedCanon(model: any, canon: string): boolean {
|
|
5
|
+
const [zone, name] = String(canon).split('/')
|
|
6
|
+
const def = model && model.main && model.main.ent &&
|
|
7
|
+
model.main.ent[zone] && model.main.ent[zone][name]
|
|
8
|
+
if (null == def || 'sys' === zone) {
|
|
9
|
+
return false
|
|
10
|
+
}
|
|
11
|
+
const api = (model.main.api || {})
|
|
12
|
+
if (false === api.active) {
|
|
13
|
+
return false
|
|
14
|
+
}
|
|
15
|
+
const conf = (api.ent || {})[canon]
|
|
16
|
+
return !(conf && false === conf.active)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// All exposed canons, sorted (used by generators and get:info).
|
|
20
|
+
export function exposedCanons(model: any): string[] {
|
|
21
|
+
const out: string[] = []
|
|
22
|
+
const zones = (model && model.main && model.main.ent) || {}
|
|
23
|
+
for (const zone of Object.keys(zones)) {
|
|
24
|
+
for (const name of Object.keys(zones[zone])) {
|
|
25
|
+
const canon = zone + '/' + name
|
|
26
|
+
if (exposedCanon(model, canon)) {
|
|
27
|
+
out.push(canon)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out.sort()
|
|
32
|
+
}
|
|
33
|
+
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { exposedCanon } from './expose'
|
|
2
|
+
|
|
3
|
+
// aim:api,on:ent { op, ent:'zone/name', id?, data?, q? }
|
|
4
|
+
// One REST operation on one entity. Validates the entity is exposed by
|
|
5
|
+
// the API (model main.api) and the data against the GENERATED entity
|
|
6
|
+
// shapes (valid_gen.ts - strict: unknown fields are rejected), then
|
|
7
|
+
// delegates to the generic entity service (aim:ent,cmd:*), which
|
|
8
|
+
// enforces membership access. The caller's principal (resolved from the
|
|
9
|
+
// API key by the router) propagates via meta.custom.
|
|
10
|
+
module.exports = function make_on_ent() {
|
|
11
|
+
const makeShapes = require('./valid_gen')
|
|
12
|
+
let shapes: any = null
|
|
13
|
+
|
|
14
|
+
return async function on_ent(this: any, msg: any) {
|
|
15
|
+
const seneca = this
|
|
16
|
+
const model = seneca.context.model
|
|
17
|
+
|
|
18
|
+
shapes = shapes || makeShapes(seneca.util.Gubu)
|
|
19
|
+
|
|
20
|
+
const op = String(msg.op || '')
|
|
21
|
+
const canon = String(msg.ent || '')
|
|
22
|
+
|
|
23
|
+
if (!exposedCanon(model, canon)) {
|
|
24
|
+
return { ok: false, why: 'unknown-entity' }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if ('list' === op) {
|
|
28
|
+
return relay(await seneca.post('aim:ent,cmd:list',
|
|
29
|
+
{ ent: canon, q: coerceQ(model, canon, msg.q || {}) }))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if ('load' === op) {
|
|
33
|
+
const res = await seneca.post('aim:ent,cmd:load', { ent: canon, id: msg.id })
|
|
34
|
+
if (res.ok && null == res.item) {
|
|
35
|
+
return { ok: false, why: 'not-found' }
|
|
36
|
+
}
|
|
37
|
+
return relay(res)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if ('create' === op || 'update' === op) {
|
|
41
|
+
const shape = shapes[canon] && shapes[canon][op]
|
|
42
|
+
if (null == shape) {
|
|
43
|
+
return { ok: false, why: 'unknown-entity' }
|
|
44
|
+
}
|
|
45
|
+
let data = null == msg.data ? {} : msg.data
|
|
46
|
+
try {
|
|
47
|
+
data = shape(data)
|
|
48
|
+
}
|
|
49
|
+
catch (e: any) {
|
|
50
|
+
if ('shape' !== e.code) {
|
|
51
|
+
throw e
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
ok: false, why: 'invalid-data', message: e.message,
|
|
55
|
+
details: (e.props || []).map((p: any) =>
|
|
56
|
+
({ path: p.path, what: p.what, type: p.type })),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if ('update' === op) {
|
|
60
|
+
const existing = await seneca.post('aim:ent,cmd:load', { ent: canon, id: msg.id })
|
|
61
|
+
if (!existing.ok || null == existing.item) {
|
|
62
|
+
return { ok: false, why: existing.ok ? 'not-found' : existing.why }
|
|
63
|
+
}
|
|
64
|
+
data = Object.assign({}, existing.item.data$ ?
|
|
65
|
+
existing.item.data$(false) : existing.item, data, { id: msg.id })
|
|
66
|
+
}
|
|
67
|
+
return relay(await seneca.post('aim:ent,cmd:save', { ent: canon, item: data }))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if ('remove' === op) {
|
|
71
|
+
const existing = await seneca.post('aim:ent,cmd:load', { ent: canon, id: msg.id })
|
|
72
|
+
if (!existing.ok || null == existing.item) {
|
|
73
|
+
return { ok: false, why: existing.ok ? 'not-found' : existing.why }
|
|
74
|
+
}
|
|
75
|
+
return relay(await seneca.post('aim:ent,cmd:remove', { ent: canon, id: msg.id }))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { ok: false, why: 'unknown-op' }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Pass the ent service result through, stripping entity instances down
|
|
83
|
+
// to plain data (strict JSON out).
|
|
84
|
+
function relay(res: any) {
|
|
85
|
+
if (null == res || !res.ok) {
|
|
86
|
+
return res || { ok: false, why: 'failed' }
|
|
87
|
+
}
|
|
88
|
+
const out: any = { ok: true }
|
|
89
|
+
if (null != res.item) {
|
|
90
|
+
out.item = plain(res.item)
|
|
91
|
+
}
|
|
92
|
+
if (null != res.list) {
|
|
93
|
+
out.items = res.list.map(plain)
|
|
94
|
+
}
|
|
95
|
+
if (null != res.id) {
|
|
96
|
+
out.id = res.id
|
|
97
|
+
}
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function plain(item: any) {
|
|
102
|
+
return item && 'function' === typeof item.data$ ? item.data$(false) : item
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// HTTP query values arrive as strings; coerce them to the field's kind
|
|
106
|
+
// so exact-match filtering works (done=true, t_c=123, ...).
|
|
107
|
+
function coerceQ(model: any, canon: string, q: any) {
|
|
108
|
+
const [zone, name] = canon.split('/')
|
|
109
|
+
const fields = (model.main.ent[zone][name] || {}).field || {}
|
|
110
|
+
const out: any = {}
|
|
111
|
+
for (const k of Object.keys(q)) {
|
|
112
|
+
const kind = fields[k] && fields[k].kind
|
|
113
|
+
let v: any = q[k]
|
|
114
|
+
if ('Number' === kind) {
|
|
115
|
+
v = Number(v)
|
|
116
|
+
}
|
|
117
|
+
else if ('Boolean' === kind) {
|
|
118
|
+
v = 'false' !== v && false !== v
|
|
119
|
+
}
|
|
120
|
+
out[k] = v
|
|
121
|
+
}
|
|
122
|
+
return out
|
|
123
|
+
}
|
|
124
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Shared helper for the apikey actions (not an action file).
|
|
2
|
+
// Never expose the stored hash.
|
|
3
|
+
export function publicKey(item: any) {
|
|
4
|
+
if (null == item) {
|
|
5
|
+
return null
|
|
6
|
+
}
|
|
7
|
+
const d = 'function' === typeof item.data$ ? item.data$(false) : item
|
|
8
|
+
return { id: d.id, name: d.name, prefix: d.prefix, revoked: !!d.revoked, t_c: d.t_c }
|
|
9
|
+
}
|
|
10
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// aim:auth,change:pass — change the current user's password via @seneca/user.
|
|
2
|
+
module.exports = function make_change_pass() {
|
|
3
|
+
return async function change_pass(this: any, msg: any) {
|
|
4
|
+
const res = await this.post('sys:user,change:pass', {
|
|
5
|
+
user_id: msg.user_id,
|
|
6
|
+
password: msg.password,
|
|
7
|
+
repeat: msg.repeat,
|
|
8
|
+
})
|
|
9
|
+
return res.ok ? { ok: true } : { ok: false, why: res.why || 'change-failed' }
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import Crypto from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
import { publicKey } from './apikey_util'
|
|
4
|
+
|
|
5
|
+
// aim:auth,create:apikey { user_id, name }
|
|
6
|
+
// Create an API access key for the user. The raw key is returned ONCE
|
|
7
|
+
// (never stored): only its sha-256 hash is kept, plus a short prefix for
|
|
8
|
+
// display. Keys authenticate REST API calls (Authorization: Bearer).
|
|
9
|
+
module.exports = function make_create_apikey() {
|
|
10
|
+
return async function create_apikey(this: any, msg: any) {
|
|
11
|
+
const seneca = this
|
|
12
|
+
const user_id = String(msg.user_id || '')
|
|
13
|
+
const name = String(msg.name || '').trim()
|
|
14
|
+
if ('' === user_id || '' === name) {
|
|
15
|
+
return { ok: false, why: 'invalid-args' }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const key = 'vk_' + Crypto.randomBytes(24).toString('hex')
|
|
19
|
+
const hash = Crypto.createHash('sha256').update(key).digest('hex')
|
|
20
|
+
|
|
21
|
+
const item = await seneca.entity('sys/apikey').data$({
|
|
22
|
+
user_id,
|
|
23
|
+
name,
|
|
24
|
+
hash,
|
|
25
|
+
prefix: key.slice(0, 8),
|
|
26
|
+
revoked: false,
|
|
27
|
+
t_c: Date.now(),
|
|
28
|
+
}).save$()
|
|
29
|
+
|
|
30
|
+
return { ok: !!item, key, item: publicKey(item) }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { publicKey } from './apikey_util'
|
|
2
|
+
|
|
3
|
+
// aim:auth,list:apikey { user_id }
|
|
4
|
+
// List the user's API keys (hashes never leave the service).
|
|
5
|
+
module.exports = function make_list_apikey() {
|
|
6
|
+
return async function list_apikey(this: any, msg: any) {
|
|
7
|
+
const user_id = String(msg.user_id || '')
|
|
8
|
+
if ('' === user_id) {
|
|
9
|
+
return { ok: false, why: 'invalid-args' }
|
|
10
|
+
}
|
|
11
|
+
const list = await this.entity('sys/apikey').list$({ user_id })
|
|
12
|
+
return { ok: true, items: list.map(publicKey) }
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
@@ -1,6 +1,15 @@
|
|
|
1
|
+
import { publicUser } from './user_util'
|
|
2
|
+
|
|
3
|
+
// Load the auth state for a user id. The user is projected to safe
|
|
4
|
+
// fields (never the password hash or salt) - see user_util.
|
|
1
5
|
module.exports = function make_load_auth() {
|
|
2
6
|
return async function load_auth(this: any, msg: any) {
|
|
3
7
|
const user = await this.entity('sys/user').load$(msg.user_id)
|
|
4
|
-
return {
|
|
8
|
+
return {
|
|
9
|
+
ok: !!user,
|
|
10
|
+
state: user ? 'signedin' : 'signedout',
|
|
11
|
+
user: publicUser(user),
|
|
12
|
+
}
|
|
5
13
|
}
|
|
6
14
|
}
|
|
15
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// aim:auth,remind:pass — password reminder. Creates a reset code but does
|
|
2
|
+
// NOT actually send an email (this is a demo); it logs what WOULD be sent.
|
|
3
|
+
// Always returns ok, regardless of whether the email exists, to avoid
|
|
4
|
+
// leaking which addresses are registered.
|
|
5
|
+
module.exports = function make_remind_pass() {
|
|
6
|
+
return async function remind_pass(this: any, msg: any) {
|
|
7
|
+
const email = String(msg.email || '').trim()
|
|
8
|
+
if ('' === email) {
|
|
9
|
+
return { ok: true }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const found = await this.post('sys:user,get:user', { email })
|
|
13
|
+
if (found.ok && found.user) {
|
|
14
|
+
let code = '(reset-code)'
|
|
15
|
+
try {
|
|
16
|
+
const v = await this.post('sys:user,make:verify', {
|
|
17
|
+
user_id: found.user.id,
|
|
18
|
+
kind: 'pass',
|
|
19
|
+
})
|
|
20
|
+
code = (v && ((v.verify && (v.verify.id || v.verify.code)) || v.id)) || code
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
// best-effort; the reminder is a stub
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// IMPORTANT: no email is actually sent — just record what would be.
|
|
27
|
+
this.log.info('password-reminder', { to: email, code, sent: false })
|
|
28
|
+
// eslint-disable-next-line no-console
|
|
29
|
+
console.log('[PASSWORD-REMINDER] (not sent) to=' + email + ' reset-code=' + code)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { ok: true }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { publicKey } from './apikey_util'
|
|
2
|
+
|
|
3
|
+
// aim:auth,revoke:apikey { user_id, id }
|
|
4
|
+
// Revoke one of the user's API keys. The record is kept (audit trail);
|
|
5
|
+
// revoked keys fail REST authentication immediately.
|
|
6
|
+
module.exports = function make_revoke_apikey() {
|
|
7
|
+
return async function revoke_apikey(this: any, msg: any) {
|
|
8
|
+
const seneca = this
|
|
9
|
+
const user_id = String(msg.user_id || '')
|
|
10
|
+
const id = String(msg.id || '')
|
|
11
|
+
if ('' === user_id || '' === id) {
|
|
12
|
+
return { ok: false, why: 'invalid-args' }
|
|
13
|
+
}
|
|
14
|
+
const item = await seneca.entity('sys/apikey').load$(id)
|
|
15
|
+
if (null == item || item.user_id !== user_id) {
|
|
16
|
+
return { ok: false, why: 'not-found' }
|
|
17
|
+
}
|
|
18
|
+
item.revoked = true
|
|
19
|
+
const saved = await item.save$()
|
|
20
|
+
return { ok: !!saved, item: publicKey(saved) }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|