@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
|
@@ -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
|
+
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import {
|
|
2
|
+
principalOf, validCanon, scopeOf, myProjectIds, ownerFor, asOwner, asSystem,
|
|
3
|
+
publicUser,
|
|
4
|
+
} from './access'
|
|
5
|
+
|
|
6
|
+
// aim:ent,cmd:list { ent:'zone/name', q?:{...} }
|
|
7
|
+
//
|
|
8
|
+
// Rows are filtered by @seneca/owner, not here: each list runs on a
|
|
9
|
+
// delegate whose custom.sysowner names the project, and owner refines the
|
|
10
|
+
// query by the tenant axis. The membership JOIN is the one thing owner
|
|
11
|
+
// cannot express (it scopes to ONE tenant, not "every project I belong
|
|
12
|
+
// to"), so listing across projects fans out over the member rows.
|
|
13
|
+
module.exports = function make_cmd_list() {
|
|
14
|
+
return async function cmd_list(this: any, msg: any, meta: any) {
|
|
15
|
+
const seneca = this
|
|
16
|
+
const model = seneca.context.model
|
|
17
|
+
const canon = String(msg.ent || '')
|
|
18
|
+
if (!validCanon(model, canon)) {
|
|
19
|
+
return { ok: false, why: 'unknown-entity' }
|
|
20
|
+
}
|
|
21
|
+
const user = principalOf(meta)
|
|
22
|
+
if (!user) {
|
|
23
|
+
return { ok: false, why: 'not-authenticated' }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const scope = scopeOf(model, canon)
|
|
27
|
+
const q = Object.assign({}, msg.q)
|
|
28
|
+
|
|
29
|
+
// sys/user: public projection, for reference pickers.
|
|
30
|
+
if ('open' === scope.kind) {
|
|
31
|
+
const list = await asSystem(seneca).entity(canon).list$(q)
|
|
32
|
+
return { ok: true, list: list.map(publicUser) }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Not project data: owner scopes it to the acting user alone.
|
|
36
|
+
if ('user' === scope.kind) {
|
|
37
|
+
const owner = ownerFor(user, null)
|
|
38
|
+
return { ok: true, list: await asOwner(seneca, owner).entity(canon).list$(q) }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Every id below came OUT of the member table, so membership is
|
|
42
|
+
// already established - ownerFor, not ownerOf, or each one costs a
|
|
43
|
+
// redundant round trip re-proving what this list just read.
|
|
44
|
+
const myIds = await myProjectIds(seneca, user.id)
|
|
45
|
+
|
|
46
|
+
// The projects themselves: one load per membership row.
|
|
47
|
+
if ('project' === scope.kind) {
|
|
48
|
+
const projects: any[] = []
|
|
49
|
+
for (const id of myIds) {
|
|
50
|
+
const p = await asOwner(seneca, ownerFor(user, id)).entity(canon).load$(id)
|
|
51
|
+
if (p && matches(p, q)) {
|
|
52
|
+
projects.push(p)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { ok: true, list: projects }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 'scoped': one project asked for, or a union across all of mine.
|
|
59
|
+
const via = (scope as any).via
|
|
60
|
+
const ids = null != q[via] ? [q[via]] : myIds
|
|
61
|
+
if (null != q[via] && myIds.indexOf(q[via]) < 0) {
|
|
62
|
+
return { ok: false, why: 'forbidden' }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let all: any[] = []
|
|
66
|
+
for (const pid of ids) {
|
|
67
|
+
const sub = Object.assign({}, q)
|
|
68
|
+
sub[via] = pid
|
|
69
|
+
all = all.concat(
|
|
70
|
+
await asOwner(seneca, ownerFor(user, pid)).entity(canon).list$(sub))
|
|
71
|
+
}
|
|
72
|
+
return { ok: true, list: all }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Simple exact-match filter (the membership-scoped project list is loaded
|
|
77
|
+
// by id, so the query can't go through list$).
|
|
78
|
+
function matches(item: any, q: any) {
|
|
79
|
+
for (const k of Object.keys(q || {})) {
|
|
80
|
+
if (item[k] !== q[k]) {
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return true
|
|
85
|
+
}
|
|
86
|
+
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {
|
|
2
|
+
principalOf, validCanon, scopeOf, ownerOf, asOwner, asSystem, publicUser,
|
|
3
|
+
} from './access'
|
|
4
|
+
|
|
5
|
+
// aim:ent,cmd:load { ent:'zone/name', id }
|
|
6
|
+
//
|
|
7
|
+
// A load addresses a row by id, so which project it belongs to is not
|
|
8
|
+
// known from the message. Resolve that first (system read), then re-load
|
|
9
|
+
// as the caller so @seneca/owner is the thing that actually decides - a
|
|
10
|
+
// row outside the caller's project comes back null.
|
|
11
|
+
module.exports = function make_cmd_load() {
|
|
12
|
+
return async function cmd_load(this: any, msg: any, meta: any) {
|
|
13
|
+
const seneca = this
|
|
14
|
+
const model = seneca.context.model
|
|
15
|
+
const canon = String(msg.ent || '')
|
|
16
|
+
if (!validCanon(model, canon)) {
|
|
17
|
+
return { ok: false, why: 'unknown-entity' }
|
|
18
|
+
}
|
|
19
|
+
const user = principalOf(meta)
|
|
20
|
+
if (!user) {
|
|
21
|
+
return { ok: false, why: 'not-authenticated' }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const scope = scopeOf(model, canon)
|
|
25
|
+
|
|
26
|
+
// sys/user: public projection, not project data.
|
|
27
|
+
if ('open' === scope.kind) {
|
|
28
|
+
const item = await asSystem(seneca).entity(canon).load$(msg.id)
|
|
29
|
+
return { ok: true, item: publicUser(item) }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const found = await asSystem(seneca).entity(canon).load$(msg.id)
|
|
33
|
+
if (!found) {
|
|
34
|
+
return { ok: true, item: null }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const projectId = 'project' === scope.kind
|
|
38
|
+
? found.id
|
|
39
|
+
: found[(scope as any).via]
|
|
40
|
+
|
|
41
|
+
// A scoped row with no stored tenant is an anomaly, not user-scoped
|
|
42
|
+
// data - refuse it rather than let ownerOf downgrade to owner-only.
|
|
43
|
+
if ('scoped' === scope.kind && null == projectId) {
|
|
44
|
+
return { ok: false, why: 'forbidden' }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const owner = await ownerOf(seneca, user, projectId)
|
|
48
|
+
if (null == owner) {
|
|
49
|
+
return { ok: false, why: 'forbidden' }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const item = await asOwner(seneca, owner).entity(canon).load$(msg.id)
|
|
53
|
+
return null == item ? { ok: false, why: 'forbidden' } : { ok: true, item }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
principalOf, validCanon, scopeOf, ownerOf, asOwner, asSystem,
|
|
3
|
+
} from './access'
|
|
4
|
+
|
|
5
|
+
// aim:ent,cmd:remove { ent:'zone/name', id }
|
|
6
|
+
//
|
|
7
|
+
// Same shape as cmd_load: discover the row's project, then delete as the
|
|
8
|
+
// caller so @seneca/owner refines the query - a row outside the caller's
|
|
9
|
+
// project is simply not matched.
|
|
10
|
+
module.exports = function make_cmd_remove() {
|
|
11
|
+
return async function cmd_remove(this: any, msg: any, meta: any) {
|
|
12
|
+
const seneca = this
|
|
13
|
+
const model = seneca.context.model
|
|
14
|
+
const canon = String(msg.ent || '')
|
|
15
|
+
if (!validCanon(model, canon)) {
|
|
16
|
+
return { ok: false, why: 'unknown-entity' }
|
|
17
|
+
}
|
|
18
|
+
const user = principalOf(meta)
|
|
19
|
+
if (!user) {
|
|
20
|
+
return { ok: false, why: 'not-authenticated' }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const scope = scopeOf(model, canon)
|
|
24
|
+
if ('open' === scope.kind) {
|
|
25
|
+
return { ok: false, why: 'read-only' }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const found = await asSystem(seneca).entity(canon).load$(msg.id)
|
|
29
|
+
if (!found) {
|
|
30
|
+
return { ok: true }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const projectId = 'project' === scope.kind
|
|
34
|
+
? found.id
|
|
35
|
+
: found[(scope as any).via]
|
|
36
|
+
|
|
37
|
+
// A scoped row with no stored tenant is an anomaly, not user-scoped
|
|
38
|
+
// data - refuse it rather than let ownerOf downgrade to owner-only.
|
|
39
|
+
if ('scoped' === scope.kind && null == projectId) {
|
|
40
|
+
return { ok: false, why: 'forbidden' }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const owner = await ownerOf(seneca, user, projectId)
|
|
44
|
+
if (null == owner) {
|
|
45
|
+
return { ok: false, why: 'forbidden' }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Owner treats a denied remove as a silent no-op, which would report
|
|
49
|
+
// success for a row the caller cannot see. Read it back as the caller
|
|
50
|
+
// first, so remove answers like load does.
|
|
51
|
+
const mine = asOwner(seneca, owner).entity(canon)
|
|
52
|
+
if (null == await mine.load$(msg.id)) {
|
|
53
|
+
return { ok: false, why: 'forbidden' }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await mine.remove$(msg.id)
|
|
57
|
+
return { ok: true }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|