@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
@@ -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
+
@@ -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
+