@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
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import {
|
|
2
|
+
principalOf, validCanon, scopeOf, projectOf, ownerOf, asOwner, asSystem,
|
|
3
|
+
stripOwned, isDenied, OWNER_FIELDS,
|
|
4
|
+
} from './access'
|
|
5
|
+
|
|
6
|
+
// aim:ent,cmd:save { ent:'zone/name', item:{...} }
|
|
7
|
+
//
|
|
8
|
+
// Create or update. Row-level access is NOT enforced here: the save runs on
|
|
9
|
+
// a delegate carrying custom.sysowner, and @seneca/owner injects the
|
|
10
|
+
// ownership axes on create and refines the query on update - so an update
|
|
11
|
+
// naming a row outside the caller's project simply does not find it.
|
|
12
|
+
// Timestamps (t_c/t_m) are maintained by @seneca/entity-util.
|
|
13
|
+
//
|
|
14
|
+
// What this action must get right is WHICH TENANT the save acts in. On
|
|
15
|
+
// create the payload names it (there is no row to contradict). On update
|
|
16
|
+
// the tenant is read from the STORED row, and the payload's project field
|
|
17
|
+
// is pinned to it. Taking the tenant from the payload would let a caller
|
|
18
|
+
// name a project they DO belong to, pass the membership check, and have
|
|
19
|
+
// owner refine the query by that same project - overwriting someone else's
|
|
20
|
+
// row. This surface therefore does not move rows between projects.
|
|
21
|
+
module.exports = function make_cmd_save() {
|
|
22
|
+
return async function cmd_save(this: any, msg: any, meta: any) {
|
|
23
|
+
const seneca = this
|
|
24
|
+
const model = seneca.context.model
|
|
25
|
+
const canon = String(msg.ent || '')
|
|
26
|
+
if (!validCanon(model, canon)) {
|
|
27
|
+
return { ok: false, why: 'unknown-entity' }
|
|
28
|
+
}
|
|
29
|
+
const user = principalOf(meta)
|
|
30
|
+
if (!user) {
|
|
31
|
+
return { ok: false, why: 'not-authenticated' }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const scope = scopeOf(model, canon)
|
|
35
|
+
if ('open' === scope.kind) {
|
|
36
|
+
return { ok: false, why: 'read-only' }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const data = Object.assign({}, msg.item)
|
|
40
|
+
|
|
41
|
+
// An id naming no existing row is a create with a client-chosen id, not
|
|
42
|
+
// an update: there is no stored tenant, and nothing to hijack.
|
|
43
|
+
const found = null == data.id
|
|
44
|
+
? null
|
|
45
|
+
: await asSystem(seneca).entity(canon).load$(data.id)
|
|
46
|
+
|
|
47
|
+
let projectId: string | null = null
|
|
48
|
+
|
|
49
|
+
if (null == found) {
|
|
50
|
+
projectId = projectOf(model, canon, msg)
|
|
51
|
+
if ('scoped' === scope.kind && null == projectId) {
|
|
52
|
+
return { ok: false, why: 'project-required' }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A client-chosen id must move to `id$`. @seneca/owner takes the
|
|
56
|
+
// create path only when ent.id is null - with a plain id it treats
|
|
57
|
+
// the save as an update, finds no row, and fails save-not-found.
|
|
58
|
+
// `id$` is seneca-entity's "create with this id", so the row still
|
|
59
|
+
// goes through owner's create path and gets its axes injected.
|
|
60
|
+
if (null != data.id) {
|
|
61
|
+
data.id$ = data.id
|
|
62
|
+
delete data.id
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else if ('project' === scope.kind) {
|
|
66
|
+
// The project IS the tenant, so updating one acts within itself.
|
|
67
|
+
projectId = found.id
|
|
68
|
+
}
|
|
69
|
+
else if ('scoped' === scope.kind) {
|
|
70
|
+
const via = (scope as any).via
|
|
71
|
+
projectId = found[via]
|
|
72
|
+
|
|
73
|
+
// A scoped row with no stored tenant is an anomaly (orphaned or
|
|
74
|
+
// legacy data), NOT user-scoped data. Refuse it rather than let
|
|
75
|
+
// ownerOf fall back to owner-only scoping, which would hand its
|
|
76
|
+
// creator access outside any project.
|
|
77
|
+
if (null == projectId) {
|
|
78
|
+
return { ok: false, why: 'forbidden' }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Pin the tenant to the stored row. `via` is whatever field the
|
|
82
|
+
// model points at proj/project, so it is NOT necessarily one of the
|
|
83
|
+
// owner fields stripOwned removes - overwrite it explicitly, or a
|
|
84
|
+
// model naming it anything but project_id lets the payload move the
|
|
85
|
+
// row to another project.
|
|
86
|
+
data[via] = projectId
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const owner = await ownerOf(seneca, user, projectId)
|
|
90
|
+
if (null == owner) {
|
|
91
|
+
return { ok: false, why: 'forbidden' }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The ownership axes come from the delegate, never the payload - a row
|
|
95
|
+
// loaded by the client carries them, and re-sending them unchanged
|
|
96
|
+
// would otherwise be read as a claim.
|
|
97
|
+
stripOwned(data)
|
|
98
|
+
|
|
99
|
+
// An update keeps the row's STORED ownership. stripOwned has just
|
|
100
|
+
// removed the client's copy, and owner injects any axis it finds
|
|
101
|
+
// null - so without this, one member editing another member's row
|
|
102
|
+
// silently transfers its owner_id to the editor (the `member` role
|
|
103
|
+
// relaxes writes on that axis, so the plugin raises no objection).
|
|
104
|
+
if (null != found) {
|
|
105
|
+
for (const f of OWNER_FIELDS) {
|
|
106
|
+
if (null != found[f]) {
|
|
107
|
+
data[f] = found[f]
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let item: any
|
|
113
|
+
try {
|
|
114
|
+
item = await asOwner(seneca, owner).entity(canon).data$(data).save$()
|
|
115
|
+
}
|
|
116
|
+
catch (err: any) {
|
|
117
|
+
if (isDenied(err)) {
|
|
118
|
+
return { ok: false, why: 'forbidden' }
|
|
119
|
+
}
|
|
120
|
+
throw err
|
|
121
|
+
}
|
|
122
|
+
if (null == item) {
|
|
123
|
+
return { ok: false, why: 'forbidden' }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Creating a project makes the creator its first (owner) member. This
|
|
127
|
+
// is bookkeeping for a project the caller is not yet a member of, so
|
|
128
|
+
// it cannot run as `member` - see the `system` role in basic.ts.
|
|
129
|
+
if ('project' === scope.kind && null == found) {
|
|
130
|
+
await asSystem(seneca).entity('proj/member').data$({
|
|
131
|
+
project_id: item.id,
|
|
132
|
+
user_id: user.id,
|
|
133
|
+
role: 'owner',
|
|
134
|
+
owner_id: user.id,
|
|
135
|
+
}).save$()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { ok: true, item }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Browser proxy: aim:web,on:ent,cmd:list -> aim:ent,cmd:list
|
|
2
|
+
//
|
|
3
|
+
// aim:web is the only namespace the gateway accepts from a browser, so
|
|
4
|
+
// every entity operation a browser may perform is declared as one of
|
|
5
|
+
// these proxies. The principal the gateway resolved travels with the
|
|
6
|
+
// message (meta.custom), so the real action scopes by membership.
|
|
7
|
+
module.exports = function make_web_cmd_list() {
|
|
8
|
+
return async function web_cmd_list(this: any, msg: any) {
|
|
9
|
+
return this.post('aim:ent,cmd:list', {
|
|
10
|
+
ent: msg.ent,
|
|
11
|
+
id: msg.id,
|
|
12
|
+
q: msg.q,
|
|
13
|
+
item: msg.item,
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Browser proxy: aim:web,on:ent,cmd:load -> aim:ent,cmd:load
|
|
2
|
+
//
|
|
3
|
+
// aim:web is the only namespace the gateway accepts from a browser, so
|
|
4
|
+
// every entity operation a browser may perform is declared as one of
|
|
5
|
+
// these proxies. The principal the gateway resolved travels with the
|
|
6
|
+
// message (meta.custom), so the real action scopes by membership.
|
|
7
|
+
module.exports = function make_web_cmd_load() {
|
|
8
|
+
return async function web_cmd_load(this: any, msg: any) {
|
|
9
|
+
return this.post('aim:ent,cmd:load', {
|
|
10
|
+
ent: msg.ent,
|
|
11
|
+
id: msg.id,
|
|
12
|
+
q: msg.q,
|
|
13
|
+
item: msg.item,
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Browser proxy: aim:web,on:ent,cmd:remove -> aim:ent,cmd:remove
|
|
2
|
+
//
|
|
3
|
+
// aim:web is the only namespace the gateway accepts from a browser, so
|
|
4
|
+
// every entity operation a browser may perform is declared as one of
|
|
5
|
+
// these proxies. The principal the gateway resolved travels with the
|
|
6
|
+
// message (meta.custom), so the real action scopes by membership.
|
|
7
|
+
module.exports = function make_web_cmd_remove() {
|
|
8
|
+
return async function web_cmd_remove(this: any, msg: any) {
|
|
9
|
+
return this.post('aim:ent,cmd:remove', {
|
|
10
|
+
ent: msg.ent,
|
|
11
|
+
id: msg.id,
|
|
12
|
+
q: msg.q,
|
|
13
|
+
item: msg.item,
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Browser proxy: aim:web,on:ent,cmd:save -> aim:ent,cmd:save
|
|
2
|
+
//
|
|
3
|
+
// aim:web is the only namespace the gateway accepts from a browser, so
|
|
4
|
+
// every entity operation a browser may perform is declared as one of
|
|
5
|
+
// these proxies. The principal the gateway resolved travels with the
|
|
6
|
+
// message (meta.custom), so the real action scopes by membership.
|
|
7
|
+
module.exports = function make_web_cmd_save() {
|
|
8
|
+
return async function web_cmd_save(this: any, msg: any) {
|
|
9
|
+
return this.post('aim:ent,cmd:save', {
|
|
10
|
+
ent: msg.ent,
|
|
11
|
+
id: msg.id,
|
|
12
|
+
q: msg.q,
|
|
13
|
+
item: msg.item,
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { test, describe } from 'node:test'
|
|
2
|
+
import assert from 'node:assert'
|
|
3
|
+
|
|
4
|
+
import Crypto from 'node:crypto'
|
|
5
|
+
|
|
6
|
+
import Seneca from 'seneca'
|
|
7
|
+
|
|
8
|
+
import Model from '../../../../model/model.json'
|
|
9
|
+
import { pickEntity } from '../../srv/api/api.setup'
|
|
10
|
+
|
|
11
|
+
// The router under test, plus a stub-friendly seneca. The API service and
|
|
12
|
+
// the entity service are stood in for with MOCK MESSAGES, so this covers
|
|
13
|
+
// the HTTP mapping alone: routing, key auth, status codes, result shaping.
|
|
14
|
+
const { apiHandler } = require('../../../../dist/env/web/api.js')
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
// The entity and the base path come from the model, so this suite holds
|
|
18
|
+
// for any entity graph.
|
|
19
|
+
const ENT = pickEntity()
|
|
20
|
+
const API = (Model as any).main.api || {}
|
|
21
|
+
const BASE = '/' + (API.version || 'v1') + '/' + (ENT ? ENT.canon : 'none/none')
|
|
22
|
+
|
|
23
|
+
const KEY = 'vk_' + 'a'.repeat(48)
|
|
24
|
+
const HASH = Crypto.createHash('sha256').update(KEY).digest('hex')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async function makeSeneca(opts: {
|
|
28
|
+
keys?: any[], // sys/apikey rows
|
|
29
|
+
users?: any[], // sys/user rows
|
|
30
|
+
onEnt?: (msg: any) => any,
|
|
31
|
+
} = {}) {
|
|
32
|
+
const seneca = Seneca({ legacy: false, timeout: 2222, debug: { undead: true } })
|
|
33
|
+
seneca.context.model = Model
|
|
34
|
+
seneca.test().use('promisify').use('entity')
|
|
35
|
+
|
|
36
|
+
seneca.message('aim:api,on:ent', async function (msg: any) {
|
|
37
|
+
return opts.onEnt ? opts.onEnt(msg) : { ok: true, items: [], item: null }
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
await seneca.ready()
|
|
41
|
+
|
|
42
|
+
for (const k of opts.keys || []) {
|
|
43
|
+
await seneca.entity('sys/apikey').data$(k).save$()
|
|
44
|
+
}
|
|
45
|
+
for (const u of opts.users || []) {
|
|
46
|
+
await seneca.entity('sys/user').data$(u).save$()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return seneca
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
// Minimal express-alike req/res.
|
|
54
|
+
function reqres(method: string, path: string, opts: {
|
|
55
|
+
headers?: Record<string, string>, body?: any, query?: any,
|
|
56
|
+
} = {}) {
|
|
57
|
+
const req = {
|
|
58
|
+
method,
|
|
59
|
+
path,
|
|
60
|
+
headers: opts.headers || {},
|
|
61
|
+
body: opts.body,
|
|
62
|
+
query: opts.query || {},
|
|
63
|
+
}
|
|
64
|
+
const res: any = {
|
|
65
|
+
statusCode: 200,
|
|
66
|
+
body: undefined as any,
|
|
67
|
+
sentFile: undefined as any,
|
|
68
|
+
type() {
|
|
69
|
+
return res
|
|
70
|
+
},
|
|
71
|
+
status(code: number) {
|
|
72
|
+
res.statusCode = code
|
|
73
|
+
return res
|
|
74
|
+
},
|
|
75
|
+
send(payload: any) {
|
|
76
|
+
res.body = payload
|
|
77
|
+
return res
|
|
78
|
+
},
|
|
79
|
+
sendFile(p: string) {
|
|
80
|
+
res.sentFile = p
|
|
81
|
+
return res
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
return { req, res }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
const authed = { authorization: 'Bearer ' + KEY }
|
|
89
|
+
|
|
90
|
+
function liveKey() {
|
|
91
|
+
return [{ id: 'k01', user_id: 'u01', name: 'ci', hash: HASH, revoked: false }]
|
|
92
|
+
}
|
|
93
|
+
function liveUser() {
|
|
94
|
+
return [{ id: 'u01', email: 'alice@ex.com', name: 'Alice', pass: 'SECRET', salt: 'S' }]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
describe('rest api router', () => {
|
|
99
|
+
|
|
100
|
+
// Every routed case needs an exposed entity to address.
|
|
101
|
+
const needEnt = (t: any) => (ENT ? false : (t.skip('model exposes no entities'), true))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
test('serves the generated openapi spec unauthenticated', async () => {
|
|
105
|
+
const seneca = await makeSeneca()
|
|
106
|
+
const handle = apiHandler(seneca, Model)
|
|
107
|
+
|
|
108
|
+
const { req, res } = reqres('GET', '/openapi.json')
|
|
109
|
+
await handle(req, res)
|
|
110
|
+
assert.ok(String(res.sentFile).endsWith('openapi.json'))
|
|
111
|
+
await seneca.close()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
test('requires a valid, unrevoked key', async (t) => {
|
|
116
|
+
if (needEnt(t)) { return }
|
|
117
|
+
const seneca = await makeSeneca({ keys: liveKey(), users: liveUser() })
|
|
118
|
+
const handle = apiHandler(seneca, Model)
|
|
119
|
+
|
|
120
|
+
// No header.
|
|
121
|
+
let rr = reqres('GET', BASE)
|
|
122
|
+
await handle(rr.req, rr.res)
|
|
123
|
+
assert.strictEqual(rr.res.statusCode, 401)
|
|
124
|
+
assert.strictEqual(rr.res.body.error.code, 'not-authenticated')
|
|
125
|
+
|
|
126
|
+
// Malformed header.
|
|
127
|
+
rr = reqres('GET', BASE, { headers: { authorization: 'Basic xyz' } })
|
|
128
|
+
await handle(rr.req, rr.res)
|
|
129
|
+
assert.strictEqual(rr.res.statusCode, 401)
|
|
130
|
+
|
|
131
|
+
// Unknown key.
|
|
132
|
+
rr = reqres('GET', BASE,
|
|
133
|
+
{ headers: { authorization: 'Bearer vk_nope' } })
|
|
134
|
+
await handle(rr.req, rr.res)
|
|
135
|
+
assert.strictEqual(rr.res.statusCode, 401)
|
|
136
|
+
|
|
137
|
+
// Valid key.
|
|
138
|
+
rr = reqres('GET', BASE, { headers: authed })
|
|
139
|
+
await handle(rr.req, rr.res)
|
|
140
|
+
assert.strictEqual(rr.res.statusCode, 200)
|
|
141
|
+
|
|
142
|
+
await seneca.close()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
test('a revoked key fails immediately', async (t) => {
|
|
147
|
+
if (needEnt(t)) { return }
|
|
148
|
+
const seneca = await makeSeneca({
|
|
149
|
+
keys: [{ id: 'k01', user_id: 'u01', hash: HASH, revoked: true }],
|
|
150
|
+
users: liveUser(),
|
|
151
|
+
})
|
|
152
|
+
const handle = apiHandler(seneca, Model)
|
|
153
|
+
|
|
154
|
+
const { req, res } = reqres('GET', BASE, { headers: authed })
|
|
155
|
+
await handle(req, res)
|
|
156
|
+
assert.strictEqual(res.statusCode, 401)
|
|
157
|
+
await seneca.close()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
test('a key whose user is gone fails', async (t) => {
|
|
162
|
+
if (needEnt(t)) { return }
|
|
163
|
+
const seneca = await makeSeneca({ keys: liveKey() })
|
|
164
|
+
const handle = apiHandler(seneca, Model)
|
|
165
|
+
|
|
166
|
+
const { req, res } = reqres('GET', BASE, { headers: authed })
|
|
167
|
+
await handle(req, res)
|
|
168
|
+
assert.strictEqual(res.statusCode, 401)
|
|
169
|
+
await seneca.close()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
test('maps methods and paths onto api operations', async (t) => {
|
|
174
|
+
if (needEnt(t)) { return }
|
|
175
|
+
const seen: any[] = []
|
|
176
|
+
const seneca = await makeSeneca({
|
|
177
|
+
keys: liveKey(), users: liveUser(),
|
|
178
|
+
onEnt: (msg: any) => {
|
|
179
|
+
seen.push({ op: msg.op, ent: msg.ent, id: msg.id, data: msg.data, q: msg.q })
|
|
180
|
+
return 'remove' === msg.op ? { ok: true, id: msg.id }
|
|
181
|
+
: 'list' === msg.op ? { ok: true, items: [{ id: 'p01' }] }
|
|
182
|
+
: { ok: true, item: { id: msg.id || 'p01' } }
|
|
183
|
+
},
|
|
184
|
+
})
|
|
185
|
+
const handle = apiHandler(seneca, Model)
|
|
186
|
+
|
|
187
|
+
let rr = reqres('GET', BASE, { headers: authed, query: { name: 'x' } })
|
|
188
|
+
await handle(rr.req, rr.res)
|
|
189
|
+
assert.strictEqual(rr.res.statusCode, 200)
|
|
190
|
+
assert.deepStrictEqual(rr.res.body.items, [{ id: 'p01' }])
|
|
191
|
+
assert.deepStrictEqual(seen[0], {
|
|
192
|
+
op: 'list', ent: ENT!.canon, id: undefined, data: undefined, q: { name: 'x' },
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
rr = reqres('GET', BASE + '/p01', { headers: authed })
|
|
196
|
+
await handle(rr.req, rr.res)
|
|
197
|
+
assert.strictEqual(seen[1].op, 'load')
|
|
198
|
+
assert.strictEqual(seen[1].id, 'p01')
|
|
199
|
+
|
|
200
|
+
rr = reqres('POST', BASE, { headers: authed, body: { name: 'New' } })
|
|
201
|
+
await handle(rr.req, rr.res)
|
|
202
|
+
// Creation answers 201.
|
|
203
|
+
assert.strictEqual(rr.res.statusCode, 201)
|
|
204
|
+
assert.strictEqual(seen[2].op, 'create')
|
|
205
|
+
assert.deepStrictEqual(seen[2].data, { name: 'New' })
|
|
206
|
+
|
|
207
|
+
rr = reqres('PUT', BASE + '/p01', { headers: authed, body: { note: 'n' } })
|
|
208
|
+
await handle(rr.req, rr.res)
|
|
209
|
+
assert.strictEqual(seen[3].op, 'update')
|
|
210
|
+
|
|
211
|
+
rr = reqres('DELETE', BASE + '/p01', { headers: authed })
|
|
212
|
+
await handle(rr.req, rr.res)
|
|
213
|
+
assert.strictEqual(seen[4].op, 'remove')
|
|
214
|
+
assert.deepStrictEqual(rr.res.body, { ok: true, id: 'p01' })
|
|
215
|
+
|
|
216
|
+
await seneca.close()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
test('rejects unroutable paths and method/path combinations', async (t) => {
|
|
221
|
+
if (needEnt(t)) { return }
|
|
222
|
+
const seneca = await makeSeneca({ keys: liveKey(), users: liveUser() })
|
|
223
|
+
const handle = apiHandler(seneca, Model)
|
|
224
|
+
|
|
225
|
+
// Wrong version, too short, too long.
|
|
226
|
+
for (const p of ['/v9/a/b', '/v1/a', '/v1/a/b/c/d']) {
|
|
227
|
+
const rr = reqres('GET', p, { headers: authed })
|
|
228
|
+
await handle(rr.req, rr.res)
|
|
229
|
+
assert.strictEqual(rr.res.statusCode, 404, p)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// POST to an id path, PUT/DELETE to a collection path.
|
|
233
|
+
for (const [m, p] of [['POST', BASE + '/p01'],
|
|
234
|
+
['PUT', BASE], ['DELETE', BASE]]) {
|
|
235
|
+
const rr = reqres(m, p, { headers: authed })
|
|
236
|
+
await handle(rr.req, rr.res)
|
|
237
|
+
assert.strictEqual(rr.res.statusCode, 405, m + ' ' + p)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
await seneca.close()
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
test('maps service failures onto http status codes', async (t) => {
|
|
245
|
+
if (needEnt(t)) { return }
|
|
246
|
+
const cases: [string, number][] = [
|
|
247
|
+
['unknown-entity', 404],
|
|
248
|
+
['not-found', 404],
|
|
249
|
+
['forbidden', 403],
|
|
250
|
+
['invalid-data', 400],
|
|
251
|
+
['project-required', 400],
|
|
252
|
+
['read-only', 405],
|
|
253
|
+
['not-authenticated', 401],
|
|
254
|
+
['something-else', 500],
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
for (const [why, status] of cases) {
|
|
258
|
+
const seneca = await makeSeneca({
|
|
259
|
+
keys: liveKey(), users: liveUser(),
|
|
260
|
+
onEnt: () => ({ ok: false, why, message: 'nope', details: [{ path: 'x' }] }),
|
|
261
|
+
})
|
|
262
|
+
const handle = apiHandler(seneca, Model)
|
|
263
|
+
|
|
264
|
+
const { req, res } = reqres('GET', BASE, { headers: authed })
|
|
265
|
+
await handle(req, res)
|
|
266
|
+
assert.strictEqual(res.statusCode, status, why)
|
|
267
|
+
assert.strictEqual(res.body.error.code, why)
|
|
268
|
+
assert.strictEqual(res.body.error.message, 'nope')
|
|
269
|
+
assert.ok(res.body.error.details)
|
|
270
|
+
await seneca.close()
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
test('an unexpected error becomes a 500', async (t) => {
|
|
276
|
+
if (needEnt(t)) { return }
|
|
277
|
+
const seneca = await makeSeneca({
|
|
278
|
+
keys: liveKey(), users: liveUser(),
|
|
279
|
+
onEnt: () => {
|
|
280
|
+
throw new Error('boom')
|
|
281
|
+
},
|
|
282
|
+
})
|
|
283
|
+
const handle = apiHandler(seneca, Model)
|
|
284
|
+
|
|
285
|
+
const { req, res } = reqres('GET', BASE, { headers: authed })
|
|
286
|
+
await handle(req, res)
|
|
287
|
+
assert.strictEqual(res.statusCode, 500)
|
|
288
|
+
assert.strictEqual(res.body.error.code, 'error')
|
|
289
|
+
await seneca.close()
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
test('the principal never carries credentials', async (t) => {
|
|
294
|
+
if (needEnt(t)) { return }
|
|
295
|
+
let principal: any = null
|
|
296
|
+
const seneca = await makeSeneca({
|
|
297
|
+
keys: liveKey(), users: liveUser(),
|
|
298
|
+
onEnt: (msg: any) => {
|
|
299
|
+
principal = msg.custom$.principal.user
|
|
300
|
+
return { ok: true, items: [] }
|
|
301
|
+
},
|
|
302
|
+
})
|
|
303
|
+
const handle = apiHandler(seneca, Model)
|
|
304
|
+
|
|
305
|
+
const { req, res } = reqres('GET', BASE, { headers: authed })
|
|
306
|
+
await handle(req, res)
|
|
307
|
+
|
|
308
|
+
assert.strictEqual(principal.id, 'u01')
|
|
309
|
+
assert.strictEqual(principal.email, 'alice@ex.com')
|
|
310
|
+
assert.strictEqual(principal.pass, undefined)
|
|
311
|
+
assert.strictEqual(principal.salt, undefined)
|
|
312
|
+
await seneca.close()
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { test, describe } from 'node:test'
|
|
2
|
+
import assert from 'node:assert'
|
|
3
|
+
|
|
4
|
+
import Model from '../../../../model/model.json'
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
// THE BROWSER SURFACE.
|
|
8
|
+
//
|
|
9
|
+
// A browser may only send aim:web messages: the gateway allow-list in
|
|
10
|
+
// src/env/web/web.ts names that one namespace, and every message the SPA
|
|
11
|
+
// sends is declared in the model as an aim:web proxy that forwards to the
|
|
12
|
+
// real service message. These tests pin that contract from the model
|
|
13
|
+
// itself, so widening the surface cannot pass unnoticed.
|
|
14
|
+
|
|
15
|
+
const MSG: any = (Model as any).main.msg.aim
|
|
16
|
+
const SRV: any = (Model as any).main.srv
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
// Every leaf message pattern under a subtree.
|
|
20
|
+
function leaves(node: any, path: string[] = []): string[][] {
|
|
21
|
+
const keys = Object.keys(node || {}).filter((k) => '$' !== k)
|
|
22
|
+
if (0 === keys.length) {
|
|
23
|
+
return [path]
|
|
24
|
+
}
|
|
25
|
+
const out: string[][] = []
|
|
26
|
+
for (const k of keys) {
|
|
27
|
+
if (node[k] && 'object' === typeof node[k]) {
|
|
28
|
+
out.push(...leaves(node[k], [...path, k]))
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
describe('browser surface', () => {
|
|
36
|
+
|
|
37
|
+
test('the gateway allows aim:web and nothing else', () => {
|
|
38
|
+
const src = require('node:fs').readFileSync(
|
|
39
|
+
__dirname + '/../../../../src/env/web/web.ts', 'utf8')
|
|
40
|
+
|
|
41
|
+
// The allow-list is a literal so it can be read off the source and
|
|
42
|
+
// reviewed: no computed expansion over the service list.
|
|
43
|
+
const m = src.match(/allow:\s*\{([^}]*)\}/)
|
|
44
|
+
assert.ok(m, 'gateway allow-list not found')
|
|
45
|
+
const keys = m[1].match(/'([^']+)'/g) || []
|
|
46
|
+
assert.deepStrictEqual(keys, ["'aim:web'"])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
test('every browser message is a declared aim:web proxy', () => {
|
|
51
|
+
const web = MSG.web
|
|
52
|
+
assert.ok(web, 'no aim:web messages declared')
|
|
53
|
+
|
|
54
|
+
// Each proxy names the action file that forwards it: proxies are
|
|
55
|
+
// explicit, never implicit pass-through.
|
|
56
|
+
for (const path of leaves(web.on || {})) {
|
|
57
|
+
const node = path.reduce((n: any, k: string) => n[k], web.on)
|
|
58
|
+
const file = node && node.$ && node.$.file
|
|
59
|
+
assert.ok(file, 'aim:web,on:' + path.join(',') + ' has no proxy action file')
|
|
60
|
+
assert.ok(String(file).includes('web_'),
|
|
61
|
+
'proxy action should be a web_ file: ' + file)
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
test('service namespaces are not part of the browser surface', () => {
|
|
67
|
+
// Every service in the model owns a message namespace of its own...
|
|
68
|
+
const services = Object.keys(SRV)
|
|
69
|
+
assert.ok(0 < services.length)
|
|
70
|
+
for (const name of services) {
|
|
71
|
+
assert.ok(MSG[name], 'expected service messages for aim:' + name)
|
|
72
|
+
// ...which is NOT the browser namespace.
|
|
73
|
+
assert.notStrictEqual(name, 'web')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ...and no service declares that it accepts the aim:web namespace
|
|
77
|
+
// wholesale: each names only the proxy subtree it owns.
|
|
78
|
+
for (const name of Object.keys(SRV)) {
|
|
79
|
+
const inweb = SRV[name].in && SRV[name].in.aim && SRV[name].in.aim.web
|
|
80
|
+
if (null == inweb) {
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
assert.deepStrictEqual(Object.keys(inweb), ['on'],
|
|
84
|
+
'srv ' + name + ' should only claim aim:web,on:<subtree>')
|
|
85
|
+
const owned = Object.keys(inweb.on)
|
|
86
|
+
assert.ok(0 < owned.length)
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
test('the entity service is reachable only through its proxies', () => {
|
|
92
|
+
const ent = MSG.web.on.ent
|
|
93
|
+
assert.ok(ent, 'no aim:web,on:ent proxies')
|
|
94
|
+
|
|
95
|
+
// Every command the generic entity service answers has a proxy, so
|
|
96
|
+
// the SPA never needs to post aim:ent directly.
|
|
97
|
+
const commands = Object.keys(MSG.ent.cmd).sort()
|
|
98
|
+
assert.deepStrictEqual(Object.keys(ent.cmd).sort(), commands)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
test('the REST API namespace is not browser-reachable', () => {
|
|
103
|
+
// aim:api is the API-key clients' proxy layer, reached through the
|
|
104
|
+
// REST router - it must not appear in the browser surface.
|
|
105
|
+
assert.strictEqual(MSG.web.on.api, undefined)
|
|
106
|
+
assert.ok(MSG.api, 'aim:api messages should still exist for the router')
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|