@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voxgig/build",
3
- "version": "4.4.0",
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": "jest --coverage",
23
- "test-some": "jest -t",
24
- "test-watch": "jest --coverage --watchAll",
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 web/dist, the model, and a single seneca
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
- // Allow auth + every service the model declares (aim:<srv>).
54
- allow: Object.keys((Model as any).main.srv).reduce(
55
- (a: any, n: string) => (a['aim:' + n] = true, a),
56
- { 'aim:req,on:auth': true }),
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
- // web/ is a sibling of backend/; from dist/env/local go up to the
101
- // project root, then web/dist.
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, '..', '..', '..', '..', 'web', 'dist')
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,4 @@
1
+ import { MakeSrv } from '@voxgig/system'
2
+
3
+ module.exports = MakeSrv('api', require)
4
+
@@ -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,6 @@
1
+ module.exports = function make_get_info() {
2
+ return async function get_info(this: any, _msg: any) {
3
+ return { ok: true, srv: 'api' }
4
+ }
5
+ }
6
+
@@ -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
+
@@ -2,3 +2,4 @@
2
2
  import { MakeSrv } from '@voxgig/system'
3
3
 
4
4
  module.exports = MakeSrv('auth', require)
5
+
@@ -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
+
@@ -3,3 +3,4 @@ module.exports = function make_get_info() {
3
3
  return { ok: true, srv: 'auth' }
4
4
  }
5
5
  }
6
+
@@ -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 { ok: !!user, state: user ? 'signedin' : 'signedout', user }
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
+