@voxgig/build 4.3.0 → 4.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -2
- package/build.ts +20 -0
- package/dist/api/api_gen.d.ts +7 -0
- package/dist/api/api_gen.js +370 -0
- package/dist/api/api_gen.js.map +1 -0
- package/dist/build.d.ts +16 -1
- package/dist/build.js +17 -1
- package/dist/build.js.map +1 -1
- package/dist/doc/doc_gen.d.ts +8 -0
- package/dist/doc/doc_gen.js +397 -0
- package/dist/doc/doc_gen.js.map +1 -0
- package/dist/env/web/web_gen.js +159 -1
- package/dist/env/web/web_gen.js.map +1 -1
- package/dist/shape/ent.js +4 -2
- package/dist/shape/ent.js.map +1 -1
- package/env/web/web_gen.ts +174 -1
- package/package.json +6 -9
- package/tm/web/backend/env/shared/seed.ts.frag +23 -0
- package/tm/web/backend/env/web/api.ts.frag +157 -0
- package/tm/web/backend/env/web/web.ts.frag +37 -4
- package/tm/web/backend/srv/api/api-srv.ts.frag +4 -0
- package/tm/web/backend/srv/api/expose.ts.frag +33 -0
- package/tm/web/backend/srv/api/get_info.ts.frag +6 -0
- package/tm/web/backend/srv/api/on_ent.ts.frag +124 -0
- package/tm/web/backend/srv/auth/apikey_util.ts.frag +10 -0
- package/tm/web/backend/srv/auth/auth-srv.ts.frag +1 -0
- package/tm/web/backend/srv/auth/change_pass.ts.frag +12 -0
- package/tm/web/backend/srv/auth/create_apikey.ts.frag +33 -0
- package/tm/web/backend/srv/auth/get_info.ts.frag +1 -0
- package/tm/web/backend/srv/auth/list_apikey.ts.frag +15 -0
- package/tm/web/backend/srv/auth/load_auth.ts.frag +10 -1
- package/tm/web/backend/srv/auth/remind_pass.ts.frag +35 -0
- package/tm/web/backend/srv/auth/revoke_apikey.ts.frag +23 -0
- package/tm/web/backend/srv/auth/signin_user.ts.frag +12 -2
- package/tm/web/backend/srv/auth/signout_user.ts.frag +11 -1
- package/tm/web/backend/srv/auth/update_user.ts.frag +15 -0
- package/tm/web/backend/srv/auth/user_util.ts.frag +21 -0
- package/tm/web/backend/srv/auth/web_change_pass.ts.frag +16 -0
- package/tm/web/backend/srv/auth/web_create_apikey.ts.frag +15 -0
- package/tm/web/backend/srv/auth/web_list_apikey.ts.frag +11 -0
- package/tm/web/backend/srv/auth/web_load_auth.ts.frag +1 -0
- package/tm/web/backend/srv/auth/web_remind_pass.ts.frag +7 -0
- package/tm/web/backend/srv/auth/web_revoke_apikey.ts.frag +14 -0
- package/tm/web/backend/srv/auth/web_signin_user.ts.frag +1 -0
- package/tm/web/backend/srv/auth/web_signout_user.ts.frag +11 -3
- package/tm/web/backend/srv/auth/web_update_user.ts.frag +11 -0
- package/tm/web/backend/srv/ent/access.ts.frag +179 -0
- package/tm/web/backend/srv/ent/cmd_list.ts.frag +86 -0
- package/tm/web/backend/srv/ent/cmd_load.ts.frag +56 -0
- package/tm/web/backend/srv/ent/cmd_remove.ts.frag +60 -0
- package/tm/web/backend/srv/ent/cmd_save.ts.frag +141 -0
- package/tm/web/backend/srv/ent/ent-srv.ts.frag +4 -0
- package/tm/web/backend/srv/ent/get_info.ts.frag +6 -0
- package/tm/web/backend/srv/ent/web_cmd_list.ts.frag +17 -0
- package/tm/web/backend/srv/ent/web_cmd_load.ts.frag +17 -0
- package/tm/web/backend/srv/ent/web_cmd_remove.ts.frag +17 -0
- package/tm/web/backend/srv/ent/web_cmd_save.ts.frag +17 -0
- package/tm/web/backend/test/unit/env/web/api-router.test.ts.frag +315 -0
- package/tm/web/backend/test/unit/env/web/surface.test.ts.frag +109 -0
- package/tm/web/backend/test/unit/srv/api/api.setup.ts.frag +162 -0
- package/tm/web/backend/test/unit/srv/api/api.test.ts.frag +225 -0
- package/tm/web/backend/test/unit/srv/auth/apikey.test.ts.frag +152 -0
- package/tm/web/backend/test/unit/srv/auth/auth.setup.ts.frag +40 -0
- package/tm/web/backend/test/unit/srv/auth/session.test.ts.frag +189 -0
- package/tm/web/backend/test/unit/srv/ent/ent.setup.ts.frag +53 -0
- package/tm/web/backend/test/unit/srv/ent/proxy.test.ts.frag +109 -0
- package/tm/web/docs/explanation/web-architecture.md.frag +50 -0
- package/tm/web/docs/how-to/add-a-custom-entity-view.md.frag +53 -0
- package/tm/web/docs/how-to/change-the-theme.md.frag +63 -0
- package/tm/web/docs/how-to/customise-the-web-app.md.frag +68 -0
- package/tm/web/docs/how-to/use-the-api.md.frag +53 -0
- package/tm/web/docs/reference/web-app.md.frag +98 -0
- package/tm/web/web/AGENTS.md.frag +52 -0
- package/tm/web/web/e2e/smoke.spec.js.frag +13 -11
- package/tm/web/web/index.html.frag +3 -0
- package/tm/web/web/package.json.frag +4 -1
- package/tm/web/web/playwright.config.js.frag +6 -1
- package/tm/web/web/src/api.js.frag +92 -0
- package/tm/web/web/src/bus.js.frag +46 -23
- package/tm/web/web/src/cmp/admin.js.frag +292 -193
- 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,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
|
+
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
import { publicUser } from './user_util'
|
|
2
|
+
|
|
1
3
|
// Sign a user in via @seneca/user login (email + password).
|
|
4
|
+
//
|
|
5
|
+
// The user is projected to safe fields: @seneca/user answers with the
|
|
6
|
+
// full entity (password hash + salt) regardless of the `fields` option,
|
|
7
|
+
// and aim:auth messages are reachable through the gateway.
|
|
2
8
|
module.exports = function make_signin_user() {
|
|
3
9
|
return async function signin_user(this: any, msg: any) {
|
|
4
10
|
const seneca = this
|
|
@@ -6,9 +12,13 @@ module.exports = function make_signin_user() {
|
|
|
6
12
|
const out = await seneca.post('sys:user,login:user', {
|
|
7
13
|
email: msg.email,
|
|
8
14
|
password: msg.password,
|
|
9
|
-
fields: ['id', 'email', 'name', 'handle'],
|
|
10
15
|
})
|
|
11
16
|
|
|
12
|
-
|
|
17
|
+
if (!out.ok) {
|
|
18
|
+
return { ok: false, why: out.why }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return { ok: true, user: publicUser(out.user), login: out.login }
|
|
13
22
|
}
|
|
14
23
|
}
|
|
24
|
+
|
|
@@ -1,5 +1,15 @@
|
|
|
1
|
+
// End a login session.
|
|
2
|
+
//
|
|
3
|
+
// @seneca/user's logout:user needs a USER QUERY, not just a token: given
|
|
4
|
+
// only a token it answers `no-user-query` and leaves the sys/login row
|
|
5
|
+
// active - i.e. the token would keep working after sign-out. Pass the
|
|
6
|
+
// user id along with the token so the session is really revoked.
|
|
1
7
|
module.exports = function make_signout_user() {
|
|
2
8
|
return async function signout_user(this: any, msg: any) {
|
|
3
|
-
return this.post('sys:user,logout:user', {
|
|
9
|
+
return this.post('sys:user,logout:user', {
|
|
10
|
+
user_id: msg.user_id,
|
|
11
|
+
token: msg.token,
|
|
12
|
+
})
|
|
4
13
|
}
|
|
5
14
|
}
|
|
15
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// aim:auth,update:user — update the current user's profile (name, etc.).
|
|
2
|
+
module.exports = function make_update_user() {
|
|
3
|
+
return async function update_user(this: any, msg: any) {
|
|
4
|
+
const res = await this.post('sys:user,update:user', {
|
|
5
|
+
user_id: msg.user_id,
|
|
6
|
+
user_data: msg.data || {},
|
|
7
|
+
})
|
|
8
|
+
if (!res.ok) {
|
|
9
|
+
return { ok: false, why: res.why || 'update-failed' }
|
|
10
|
+
}
|
|
11
|
+
const u = res.user || {}
|
|
12
|
+
return { ok: true, user: { id: u.id, name: u.name, email: u.email, handle: u.handle } }
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Shared helper for the auth actions (not an action file).
|
|
2
|
+
//
|
|
3
|
+
// Project a user down to the fields safe to send to a client. The
|
|
4
|
+
// @seneca/user entity carries the password hash (`pass`) and `salt`, and
|
|
5
|
+
// the `fields` option of sys:user,* does NOT restrict the user object it
|
|
6
|
+
// returns - so every action that answers with a user must project it
|
|
7
|
+
// here. aim:auth messages are reachable through the gateway, so this is
|
|
8
|
+
// the difference between a client seeing an email and seeing a hash.
|
|
9
|
+
export function publicUser(user: any) {
|
|
10
|
+
if (null == user) {
|
|
11
|
+
return undefined
|
|
12
|
+
}
|
|
13
|
+
const d = 'function' === typeof user.data$ ? user.data$(false) : user
|
|
14
|
+
return {
|
|
15
|
+
id: d.id,
|
|
16
|
+
email: d.email,
|
|
17
|
+
name: d.name,
|
|
18
|
+
handle: d.handle,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Gateway wrapper: change the SIGNED-IN user's password (from the cookie
|
|
2
|
+
// principal, never a caller-supplied id).
|
|
3
|
+
module.exports = function make_web_change_pass() {
|
|
4
|
+
return async function web_change_pass(this: any, msg: any, meta: any) {
|
|
5
|
+
const user = meta?.custom?.principal?.user
|
|
6
|
+
if (!user) {
|
|
7
|
+
return { ok: false, why: 'not-authenticated' }
|
|
8
|
+
}
|
|
9
|
+
return this.post('aim:auth,change:pass', {
|
|
10
|
+
user_id: user.id,
|
|
11
|
+
password: msg.password,
|
|
12
|
+
repeat: msg.repeat,
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Gateway wrapper: create an API key for the SIGNED-IN user (from the
|
|
2
|
+
// cookie principal, never a caller-supplied id).
|
|
3
|
+
module.exports = function make_web_create_apikey() {
|
|
4
|
+
return async function web_create_apikey(this: any, msg: any, meta: any) {
|
|
5
|
+
const user = meta?.custom?.principal?.user
|
|
6
|
+
if (!user) {
|
|
7
|
+
return { ok: false, why: 'not-authenticated' }
|
|
8
|
+
}
|
|
9
|
+
return this.post('aim:auth,create:apikey', {
|
|
10
|
+
user_id: user.id,
|
|
11
|
+
name: msg.name,
|
|
12
|
+
})
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Gateway wrapper: list the SIGNED-IN user's API keys.
|
|
2
|
+
module.exports = function make_web_list_apikey() {
|
|
3
|
+
return async function web_list_apikey(this: any, _msg: any, meta: any) {
|
|
4
|
+
const user = meta?.custom?.principal?.user
|
|
5
|
+
if (!user) {
|
|
6
|
+
return { ok: false, why: 'not-authenticated' }
|
|
7
|
+
}
|
|
8
|
+
return this.post('aim:auth,list:apikey', { user_id: user.id })
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Gateway wrapper: revoke one of the SIGNED-IN user's API keys.
|
|
2
|
+
module.exports = function make_web_revoke_apikey() {
|
|
3
|
+
return async function web_revoke_apikey(this: any, msg: any, meta: any) {
|
|
4
|
+
const user = meta?.custom?.principal?.user
|
|
5
|
+
if (!user) {
|
|
6
|
+
return { ok: false, why: 'not-authenticated' }
|
|
7
|
+
}
|
|
8
|
+
return this.post('aim:auth,revoke:apikey', {
|
|
9
|
+
user_id: user.id,
|
|
10
|
+
id: msg.id,
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
@@ -1,9 +1,17 @@
|
|
|
1
|
+
// Gateway signout: revoke the session server-side, then tell
|
|
2
|
+
// gateway-auth (express_cookie) to clear the auth cookie.
|
|
1
3
|
module.exports = function make_web_signout_user() {
|
|
2
4
|
return async function web_signout_user(this: any, _msg: any, meta: any) {
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
const principal = meta.custom?.principal
|
|
6
|
+
const token = principal?.token
|
|
7
|
+
const user_id = principal?.user?.id
|
|
8
|
+
|
|
9
|
+
if (token || user_id) {
|
|
10
|
+
// Both are needed: a token alone does not revoke the login row.
|
|
11
|
+
await this.post('aim:auth,signout:user', { user_id, token })
|
|
6
12
|
}
|
|
13
|
+
|
|
7
14
|
return { ok: true, gateway$: { auth: { remove: true } } }
|
|
8
15
|
}
|
|
9
16
|
}
|
|
17
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Gateway wrapper: update the SIGNED-IN user's profile.
|
|
2
|
+
module.exports = function make_web_update_user() {
|
|
3
|
+
return async function web_update_user(this: any, msg: any, meta: any) {
|
|
4
|
+
const user = meta?.custom?.principal?.user
|
|
5
|
+
if (!user) {
|
|
6
|
+
return { ok: false, why: 'not-authenticated' }
|
|
7
|
+
}
|
|
8
|
+
return this.post('aim:auth,update:user', { user_id: user.id, data: msg.data })
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Access control support for the generic entity service.
|
|
2
|
+
//
|
|
3
|
+
// Enforcement itself lives in @seneca/owner (configured in
|
|
4
|
+
// src/env/shared/basic.ts), which scopes every entity message by the two
|
|
5
|
+
// ownership axes it finds on `custom.sysowner`:
|
|
6
|
+
//
|
|
7
|
+
// owner_id the acting user
|
|
8
|
+
// project_id the project in play (the tenant axis)
|
|
9
|
+
//
|
|
10
|
+
// This module's job is only to RESOLVE those axes from the gateway
|
|
11
|
+
// principal and the message, and to say which canons the service will
|
|
12
|
+
// answer for at all. It does not filter rows - the entity layer does.
|
|
13
|
+
//
|
|
14
|
+
// Relationships in the model are reference fields: `kind: String` plus a
|
|
15
|
+
// `ref: 'zone/name'` attribute (never `kind: 'Ref'`).
|
|
16
|
+
|
|
17
|
+
import { base } from '../../env/shared/basic'
|
|
18
|
+
|
|
19
|
+
// The ownership axes @seneca/owner manages. They are SERVER-managed: a
|
|
20
|
+
// client may neither set them nor round-trip them back on update (a loaded
|
|
21
|
+
// row carries them, and owner rejects a create/update whose payload
|
|
22
|
+
// disagrees with the acting owner - see stripOwned).
|
|
23
|
+
export const OWNER_FIELDS: string[] = base.options.owner.fields
|
|
24
|
+
|
|
25
|
+
// @seneca/owner fails writes loudly rather than returning an empty result,
|
|
26
|
+
// so a denial arrives as an error, not a null.
|
|
27
|
+
const DENIED = [
|
|
28
|
+
'create-not-allowed',
|
|
29
|
+
'update-not-allowed',
|
|
30
|
+
'save-not-found',
|
|
31
|
+
'role-entity-not-allowed',
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
export function isDenied(err: any): boolean {
|
|
35
|
+
return !!err && DENIED.indexOf(err.code) >= 0
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Drop the server-managed ownership fields from client data, so owner
|
|
39
|
+
// injects them from custom.sysowner instead of comparing them to a claim.
|
|
40
|
+
export function stripOwned(data: any): any {
|
|
41
|
+
for (const f of OWNER_FIELDS) {
|
|
42
|
+
delete data[f]
|
|
43
|
+
}
|
|
44
|
+
return data
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type Scope =
|
|
48
|
+
| { kind: 'project' }
|
|
49
|
+
| { kind: 'scoped'; via: string }
|
|
50
|
+
| { kind: 'user' }
|
|
51
|
+
| { kind: 'open' }
|
|
52
|
+
|
|
53
|
+
export function principalOf(meta: any): any {
|
|
54
|
+
return (meta && meta.custom && meta.custom.principal && meta.custom.principal.user) || null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function entDef(model: any, canon: string): any {
|
|
58
|
+
const [zone, name] = String(canon).split('/')
|
|
59
|
+
return model && model.main && model.main.ent &&
|
|
60
|
+
model.main.ent[zone] && model.main.ent[zone][name] || null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function validCanon(model: any, canon: string): boolean {
|
|
64
|
+
if (!entDef(model, canon)) {
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
const zone = String(canon).split('/')[0]
|
|
68
|
+
// Only sys/user is reachable from the sys zone (for pickers); sys/login etc. are not.
|
|
69
|
+
return 'sys' !== zone || 'sys/user' === canon
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Find the reference field that targets proj/project, if any.
|
|
73
|
+
function projectRefField(model: any, canon: string): string | null {
|
|
74
|
+
const def = entDef(model, canon)
|
|
75
|
+
const fields = (def && def.field) || {}
|
|
76
|
+
for (const fname of Object.keys(fields)) {
|
|
77
|
+
const f = fields[fname]
|
|
78
|
+
if (f && 'proj/project' === f.ref) {
|
|
79
|
+
return fname
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function scopeOf(model: any, canon: string): Scope {
|
|
86
|
+
if ('proj/project' === canon) {
|
|
87
|
+
return { kind: 'project' }
|
|
88
|
+
}
|
|
89
|
+
if ('sys/user' === canon) {
|
|
90
|
+
return { kind: 'open' }
|
|
91
|
+
}
|
|
92
|
+
const via = projectRefField(model, canon)
|
|
93
|
+
if (via) {
|
|
94
|
+
return { kind: 'scoped', via }
|
|
95
|
+
}
|
|
96
|
+
return { kind: 'user' }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Project ids the given user is a member of.
|
|
100
|
+
export async function myProjectIds(seneca: any, userId: string): Promise<string[]> {
|
|
101
|
+
if (!userId) {
|
|
102
|
+
return []
|
|
103
|
+
}
|
|
104
|
+
const members = await seneca.entity('proj/member').list$({ user_id: userId })
|
|
105
|
+
const ids: string[] = []
|
|
106
|
+
for (const m of members) {
|
|
107
|
+
if (null != m.project_id && ids.indexOf(m.project_id) < 0) {
|
|
108
|
+
ids.push(m.project_id)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return ids
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function isMember(seneca: any, userId: string, projectId: string): Promise<boolean> {
|
|
115
|
+
if (!userId || null == projectId) {
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
const m = await seneca.entity('proj/member').load$({ user_id: userId, project_id: projectId })
|
|
119
|
+
return !!m
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The project this message acts in, taken from the message itself: the
|
|
123
|
+
// query filter for a list, the item's project ref for a save, else null
|
|
124
|
+
// (load/remove address a row by id and are resolved by the caller).
|
|
125
|
+
export function projectOf(model: any, canon: string, msg: any): string | null {
|
|
126
|
+
const scope = scopeOf(model, canon)
|
|
127
|
+
if ('scoped' !== scope.kind) {
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
const via = (scope as any).via
|
|
131
|
+
const q = msg.q || {}
|
|
132
|
+
const item = msg.item || {}
|
|
133
|
+
return null != q[via] ? q[via] : (null != item[via] ? item[via] : null)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The @seneca/owner axes for a user acting in a project. Pure: use it
|
|
137
|
+
// only where membership is ALREADY established (project ids that came
|
|
138
|
+
// out of the member table). Everywhere else use ownerOf.
|
|
139
|
+
export function ownerFor(user: any, projectId: string | null): any {
|
|
140
|
+
// No project in play -> owner-only scoping (the `user` role).
|
|
141
|
+
return null == projectId
|
|
142
|
+
? { owner_id: user.id, role: 'user' }
|
|
143
|
+
: { owner_id: user.id, project_id: projectId, role: 'member' }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Same, but confirms membership first. Returns null when the caller is
|
|
147
|
+
// not a member of the requested project, which the caller turns into
|
|
148
|
+
// 'forbidden' - owner would deny the row anyway, this just gives a
|
|
149
|
+
// precise reason instead of an empty result.
|
|
150
|
+
export async function ownerOf(
|
|
151
|
+
seneca: any, user: any, projectId: string | null
|
|
152
|
+
): Promise<any> {
|
|
153
|
+
if (null == projectId) {
|
|
154
|
+
return ownerFor(user, null)
|
|
155
|
+
}
|
|
156
|
+
if (!(await isMember(seneca, user.id, projectId))) {
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
159
|
+
return ownerFor(user, projectId)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Run entity operations as the given owner: a delegate carrying
|
|
163
|
+
// custom.sysowner, which is where @seneca/owner reads the axes from.
|
|
164
|
+
export function asOwner(seneca: any, owner: any): any {
|
|
165
|
+
return seneca.delegate(null, { custom: { sysowner: owner } })
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Internal, unscoped access for bookkeeping the caller is not the owner of
|
|
169
|
+
// (membership rows, discovering which project a row belongs to). Never
|
|
170
|
+
// derived from request input - see the `system` role in basic.ts.
|
|
171
|
+
export function asSystem(seneca: any): any {
|
|
172
|
+
return asOwner(seneca, { owner_id: 'system', role: 'system' })
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Public projection of a user (never expose password/hashes).
|
|
176
|
+
export function publicUser(u: any): any {
|
|
177
|
+
return u ? { id: u.id, name: u.name, email: u.email, handle: u.handle } : u
|
|
178
|
+
}
|
|
179
|
+
|