@voxgig/build 4.4.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.
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 +159 -1
  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 +174 -1
  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 +37 -4
  21. package/tm/web/backend/srv/api/api-srv.ts.frag +4 -0
  22. package/tm/web/backend/srv/api/expose.ts.frag +33 -0
  23. package/tm/web/backend/srv/api/get_info.ts.frag +6 -0
  24. package/tm/web/backend/srv/api/on_ent.ts.frag +124 -0
  25. package/tm/web/backend/srv/auth/apikey_util.ts.frag +10 -0
  26. package/tm/web/backend/srv/auth/auth-srv.ts.frag +1 -0
  27. package/tm/web/backend/srv/auth/change_pass.ts.frag +12 -0
  28. package/tm/web/backend/srv/auth/create_apikey.ts.frag +33 -0
  29. package/tm/web/backend/srv/auth/get_info.ts.frag +1 -0
  30. package/tm/web/backend/srv/auth/list_apikey.ts.frag +15 -0
  31. package/tm/web/backend/srv/auth/load_auth.ts.frag +10 -1
  32. package/tm/web/backend/srv/auth/remind_pass.ts.frag +35 -0
  33. package/tm/web/backend/srv/auth/revoke_apikey.ts.frag +23 -0
  34. package/tm/web/backend/srv/auth/signin_user.ts.frag +12 -2
  35. package/tm/web/backend/srv/auth/signout_user.ts.frag +11 -1
  36. package/tm/web/backend/srv/auth/update_user.ts.frag +15 -0
  37. package/tm/web/backend/srv/auth/user_util.ts.frag +21 -0
  38. package/tm/web/backend/srv/auth/web_change_pass.ts.frag +16 -0
  39. package/tm/web/backend/srv/auth/web_create_apikey.ts.frag +15 -0
  40. package/tm/web/backend/srv/auth/web_list_apikey.ts.frag +11 -0
  41. package/tm/web/backend/srv/auth/web_load_auth.ts.frag +1 -0
  42. package/tm/web/backend/srv/auth/web_remind_pass.ts.frag +7 -0
  43. package/tm/web/backend/srv/auth/web_revoke_apikey.ts.frag +14 -0
  44. package/tm/web/backend/srv/auth/web_signin_user.ts.frag +1 -0
  45. package/tm/web/backend/srv/auth/web_signout_user.ts.frag +11 -3
  46. package/tm/web/backend/srv/auth/web_update_user.ts.frag +11 -0
  47. package/tm/web/backend/srv/ent/access.ts.frag +179 -0
  48. package/tm/web/backend/srv/ent/cmd_list.ts.frag +86 -0
  49. package/tm/web/backend/srv/ent/cmd_load.ts.frag +56 -0
  50. package/tm/web/backend/srv/ent/cmd_remove.ts.frag +60 -0
  51. package/tm/web/backend/srv/ent/cmd_save.ts.frag +141 -0
  52. package/tm/web/backend/srv/ent/ent-srv.ts.frag +4 -0
  53. package/tm/web/backend/srv/ent/get_info.ts.frag +6 -0
  54. package/tm/web/backend/srv/ent/web_cmd_list.ts.frag +17 -0
  55. package/tm/web/backend/srv/ent/web_cmd_load.ts.frag +17 -0
  56. package/tm/web/backend/srv/ent/web_cmd_remove.ts.frag +17 -0
  57. package/tm/web/backend/srv/ent/web_cmd_save.ts.frag +17 -0
  58. package/tm/web/backend/test/unit/env/web/api-router.test.ts.frag +315 -0
  59. package/tm/web/backend/test/unit/env/web/surface.test.ts.frag +109 -0
  60. package/tm/web/backend/test/unit/srv/api/api.setup.ts.frag +162 -0
  61. package/tm/web/backend/test/unit/srv/api/api.test.ts.frag +225 -0
  62. package/tm/web/backend/test/unit/srv/auth/apikey.test.ts.frag +152 -0
  63. package/tm/web/backend/test/unit/srv/auth/auth.setup.ts.frag +40 -0
  64. package/tm/web/backend/test/unit/srv/auth/session.test.ts.frag +189 -0
  65. package/tm/web/backend/test/unit/srv/ent/ent.setup.ts.frag +53 -0
  66. package/tm/web/backend/test/unit/srv/ent/proxy.test.ts.frag +109 -0
  67. package/tm/web/docs/explanation/web-architecture.md.frag +50 -0
  68. package/tm/web/docs/how-to/add-a-custom-entity-view.md.frag +53 -0
  69. package/tm/web/docs/how-to/change-the-theme.md.frag +63 -0
  70. package/tm/web/docs/how-to/customise-the-web-app.md.frag +68 -0
  71. package/tm/web/docs/how-to/use-the-api.md.frag +53 -0
  72. package/tm/web/docs/reference/web-app.md.frag +98 -0
  73. package/tm/web/web/AGENTS.md.frag +52 -0
  74. package/tm/web/web/e2e/smoke.spec.js.frag +13 -11
  75. package/tm/web/web/index.html.frag +1 -0
  76. package/tm/web/web/package.json.frag +1 -0
  77. package/tm/web/web/playwright.config.js.frag +2 -1
  78. package/tm/web/web/src/api.js.frag +92 -0
  79. package/tm/web/web/src/bus.js.frag +14 -8
  80. package/tm/web/web/src/cmp/admin.js.frag +293 -212
  81. package/tm/web/web/src/cmp/admin.md.frag +51 -0
  82. package/tm/web/web/src/cmp/app.js.frag +12 -15
  83. package/tm/web/web/src/cmp/app.md.frag +28 -0
  84. package/tm/web/web/src/cmp/auth.js.frag +69 -41
  85. package/tm/web/web/src/cmp/auth.md.frag +36 -0
  86. package/tm/web/web/src/cmp/public.js.frag +58 -0
  87. package/tm/web/web/src/cmp/public.md.frag +26 -0
  88. package/tm/web/web/src/cmp/settings.js.frag +178 -0
  89. package/tm/web/web/src/cmp/settings.md.frag +30 -0
  90. package/tm/web/web/src/cmp/shell.js.frag +231 -0
  91. package/tm/web/web/src/cmp/shell.md.frag +49 -0
  92. package/tm/web/web/src/cmp/view/custom-view.js.frag +47 -0
  93. package/tm/web/web/src/cmp/view/custom-view.md.frag +31 -0
  94. package/tm/web/web/src/custom.css.frag +8 -0
  95. package/tm/web/web/src/customise.js.frag +30 -0
  96. package/tm/web/web/src/hooks.js.frag +79 -0
  97. package/tm/web/web/src/main.js.frag +17 -4
  98. package/tm/web/web/src/model.js.frag +162 -0
  99. package/tm/web/web/src/style.css.frag +135 -21
  100. package/tm/web/web/src/theme.js.frag +82 -0
@@ -0,0 +1,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
+
@@ -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
- return out
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', { token: msg.token })
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
+
@@ -10,3 +10,4 @@ module.exports = function make_web_load_auth() {
10
10
  return out
11
11
  }
12
12
  }
13
+
@@ -0,0 +1,7 @@
1
+ // Gateway wrapper: public password-reminder request (no auth required).
2
+ module.exports = function make_web_remind_pass() {
3
+ return async function web_remind_pass(this: any, msg: any) {
4
+ return this.post('aim:auth,remind:pass', { email: msg.email })
5
+ }
6
+ }
7
+
@@ -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
+
@@ -23,3 +23,4 @@ module.exports = function make_web_signin_user() {
23
23
  return out
24
24
  }
25
25
  }
26
+
@@ -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 token = meta.custom?.principal?.token
4
- if (token) {
5
- await this.post('aim:auth,signout:user', { token })
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
+