@kernhq/module-inventory 0.1.1 → 0.2.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 +55 -9
- package/dist/contract/capabilities.d.ts +49 -0
- package/dist/contract/capabilities.d.ts.map +1 -0
- package/dist/contract/capabilities.js +52 -0
- package/dist/contract/capabilities.js.map +1 -0
- package/dist/contract/events.d.ts +33 -0
- package/dist/contract/events.d.ts.map +1 -0
- package/dist/contract/events.js +22 -0
- package/dist/contract/events.js.map +1 -0
- package/dist/contract/index.d.ts +15 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +15 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/contract/models.d.ts +146 -0
- package/dist/contract/models.d.ts.map +1 -0
- package/dist/contract/models.js +107 -0
- package/dist/contract/models.js.map +1 -0
- package/dist/contract/permissions.d.ts +22 -0
- package/dist/contract/permissions.d.ts.map +1 -0
- package/dist/contract/permissions.js +26 -0
- package/dist/contract/permissions.js.map +1 -0
- package/dist/{contract.d.ts → contract/router.d.ts} +24 -85
- package/dist/contract/router.d.ts.map +1 -0
- package/dist/contract/router.js +42 -0
- package/dist/contract/router.js.map +1 -0
- package/dist/contract/settings.d.ts +18 -0
- package/dist/contract/settings.d.ts.map +1 -0
- package/dist/contract/settings.js +29 -0
- package/dist/contract/settings.js.map +1 -0
- package/dist/server/index.d.ts +4 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +9 -13
- package/dist/server/index.js.map +1 -1
- package/dist/server/{_impl.d.ts → router.d.ts} +33 -8
- package/dist/server/{_impl.d.ts.map → router.d.ts.map} +1 -1
- package/dist/server/router.js +83 -0
- package/dist/server/router.js.map +1 -0
- package/dist/server/schema.d.ts +25 -11
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +28 -10
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/assets.d.ts +73 -0
- package/dist/server/services/assets.d.ts.map +1 -0
- package/dist/server/services/assets.js +261 -0
- package/dist/server/services/assets.js.map +1 -0
- package/dist/server/services/index.d.ts +10 -0
- package/dist/server/services/index.d.ts.map +1 -0
- package/dist/server/services/index.js +15 -0
- package/dist/server/services/index.js.map +1 -0
- package/dist/server/services/notify.d.ts +63 -0
- package/dist/server/services/notify.d.ts.map +1 -0
- package/dist/server/services/notify.js +105 -0
- package/dist/server/services/notify.js.map +1 -0
- package/migrations/0000_init.sql +12 -3
- package/migrations/0001_rls.sql +24 -0
- package/migrations/meta/0000_snapshot.json +40 -13
- package/migrations/meta/_journal.json +2 -2
- package/package.json +12 -9
- package/src/client/api.ts +1 -1
- package/src/client/components/AssetFormDialog.svelte +130 -47
- package/src/client/i18n.ts +11 -166
- package/src/client/index.ts +8 -1
- package/src/client/messages.test.ts +169 -0
- package/src/client/messages.ts +399 -0
- package/src/client/mock.test.ts +161 -0
- package/src/client/mock.ts +267 -45
- package/src/client/module.ts +22 -2
- package/src/client/pages/AssetsPage.svelte +358 -137
- package/src/client/permissions.ts +1 -1
- package/src/client/price.test.ts +106 -0
- package/src/client/price.ts +135 -0
- package/src/client/query.test.ts +58 -0
- package/src/client/query.ts +15 -2
- package/src/client/settings/GeneralSettings.svelte +0 -0
- package/src/client/settings/core-api.ts +32 -0
- package/src/client/widgets/OverviewWidget.svelte +16 -3
- package/src/contract/capabilities.ts +55 -0
- package/src/contract/events.ts +34 -0
- package/src/contract/index.ts +15 -0
- package/src/contract/models.ts +123 -0
- package/src/contract/permissions.ts +26 -0
- package/src/contract/router.ts +46 -0
- package/src/contract/settings.ts +30 -0
- package/src/module.test.ts +140 -7
- package/src/server/index.ts +16 -13
- package/src/server/inventory.int.test.ts +819 -0
- package/src/server/migrations.test.ts +138 -0
- package/src/server/router.ts +118 -0
- package/src/server/schema.ts +27 -10
- package/src/server/services/assets.ts +368 -0
- package/src/server/services/index.ts +23 -0
- package/src/server/services/notify.ts +151 -0
- package/tsconfig.base.json +22 -0
- package/tsconfig.client.json +1 -1
- package/tsconfig.json +1 -1
- package/vitest.config.ts +18 -3
- package/dist/contract.d.ts.map +0 -1
- package/dist/contract.js +0 -119
- package/dist/contract.js.map +0 -1
- package/dist/server/_impl.js +0 -204
- package/dist/server/_impl.js.map +0 -1
- package/src/contract.ts +0 -143
- package/src/server/_impl.ts +0 -275
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { baseContract, PageInput, page } from '@kernhq/contracts'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { Asset, AssetCreateInput, AssetPatchInput, AssetSort, AssetStatus, ws } from './models.js'
|
|
4
|
+
|
|
5
|
+
const t = ['inventory'] as const
|
|
6
|
+
|
|
7
|
+
export const inventoryContract = {
|
|
8
|
+
assets: {
|
|
9
|
+
list: baseContract
|
|
10
|
+
.route({ method: 'GET', path: '/assets', tags: t })
|
|
11
|
+
.input(
|
|
12
|
+
ws.extend({
|
|
13
|
+
...PageInput.shape,
|
|
14
|
+
q: z.string().max(200).optional(),
|
|
15
|
+
categoryId: z.uuid().optional(),
|
|
16
|
+
status: AssetStatus.optional(),
|
|
17
|
+
custodianUserId: z.uuid().optional(),
|
|
18
|
+
/**
|
|
19
|
+
* Archived rows are excluded unless asked for. The page used to filter them in the
|
|
20
|
+
* browser, which is wrong the moment there is more than one page of them: the first
|
|
21
|
+
* twenty rows come back, half are dropped, and the list looks short rather than paged.
|
|
22
|
+
*/
|
|
23
|
+
archived: z.boolean().default(false),
|
|
24
|
+
sort: AssetSort.default('recent'),
|
|
25
|
+
}),
|
|
26
|
+
)
|
|
27
|
+
.output(page(Asset)),
|
|
28
|
+
get: baseContract
|
|
29
|
+
.route({ method: 'GET', path: '/assets/{assetId}', tags: t })
|
|
30
|
+
.input(ws.extend({ assetId: z.uuid() }))
|
|
31
|
+
.output(Asset),
|
|
32
|
+
create: baseContract
|
|
33
|
+
.route({ method: 'POST', path: '/assets', tags: t })
|
|
34
|
+
.input(ws.extend(AssetCreateInput.shape))
|
|
35
|
+
.output(Asset),
|
|
36
|
+
update: baseContract
|
|
37
|
+
.route({ method: 'PATCH', path: '/assets/{assetId}', tags: t })
|
|
38
|
+
.input(ws.extend({ assetId: z.uuid(), ...AssetPatchInput.shape }))
|
|
39
|
+
.output(Asset),
|
|
40
|
+
archive: baseContract
|
|
41
|
+
.route({ method: 'POST', path: '/assets/{assetId}/archive', tags: t })
|
|
42
|
+
.input(ws.extend({ assetId: z.uuid(), archived: z.boolean().default(true) }))
|
|
43
|
+
.output(Asset),
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
export type InventoryContract = typeof inventoryContract
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Workspace-level settings for Inventory.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately small. Nearly everything an administrator configures belongs to a category, a
|
|
7
|
+
* location or a field definition, because those are the things that differ between two kinds of
|
|
8
|
+
* item in the same company. What is left is genuinely workspace-wide.
|
|
9
|
+
*
|
|
10
|
+
* Note what is *not* here: the capability switches. Those live under a reserved `$capabilities` key
|
|
11
|
+
* the platform owns, so turning one off cannot collide with a settings field and cannot be dropped
|
|
12
|
+
* by a settings round-trip.
|
|
13
|
+
*/
|
|
14
|
+
export const InventorySettings = z.object({
|
|
15
|
+
/**
|
|
16
|
+
* What an asset tag looks like. `INV-` and 4 gives `INV-0042`.
|
|
17
|
+
*
|
|
18
|
+
* People read these off a sticker and say them out loud, so a workspace that already labels its
|
|
19
|
+
* laptops `LT-` should not have to keep two numbering systems in its head. The counter itself is a
|
|
20
|
+
* row in `mod_inventory.counters`, not a setting — a number an administrator can edit is a number
|
|
21
|
+
* that produces a duplicate tag.
|
|
22
|
+
*/
|
|
23
|
+
assetCodePrefix: z.string().max(8).default('INV-'),
|
|
24
|
+
assetCodePad: z.number().int().min(1).max(10).default(4),
|
|
25
|
+
// `warrantyNoticeDays` used to sit here, describing a warranty sweep that has never existed —
|
|
26
|
+
// no job, no subscription, nothing reading the number. A setting nothing enforces is the same
|
|
27
|
+
// lie as a capability nothing checks: it teaches an administrator that the settings page does
|
|
28
|
+
// not mean anything. It comes back with the sweep.
|
|
29
|
+
})
|
|
30
|
+
export type InventorySettings = z.infer<typeof InventorySettings>
|
package/src/module.test.ts
CHANGED
|
@@ -13,9 +13,16 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import type { Kernel } from '@kernhq/kernel'
|
|
15
15
|
import { describe, expect, it } from 'vitest'
|
|
16
|
-
import {
|
|
17
|
-
|
|
16
|
+
import {
|
|
17
|
+
inventoryCapabilities,
|
|
18
|
+
inventoryCapabilityProcedures,
|
|
19
|
+
inventoryContract,
|
|
20
|
+
inventoryEvents,
|
|
21
|
+
inventoryPermissions,
|
|
22
|
+
MODULE_ID,
|
|
23
|
+
} from './contract/index.js'
|
|
18
24
|
import { inventoryModule } from './server/index.js'
|
|
25
|
+
import { inventoryRouter } from './server/router.js'
|
|
19
26
|
|
|
20
27
|
/** An oRPC procedure (contract or implementation) carries `~orpc`; a router group does not. */
|
|
21
28
|
interface Leaf {
|
|
@@ -38,7 +45,7 @@ function leaves(node: unknown, path: string[] = []): Record<string, Leaf> {
|
|
|
38
45
|
|
|
39
46
|
// The router is only inspected, never called, so it needs no real kernel behind it.
|
|
40
47
|
const declared = leaves(inventoryContract)
|
|
41
|
-
const implemented = leaves(
|
|
48
|
+
const implemented = leaves(inventoryRouter({} as Kernel))
|
|
42
49
|
|
|
43
50
|
describe('the contract and the router agree', () => {
|
|
44
51
|
it('implements every declared procedure, and nothing that was never declared', () => {
|
|
@@ -54,11 +61,89 @@ describe('the contract and the router agree', () => {
|
|
|
54
61
|
})
|
|
55
62
|
})
|
|
56
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Which middleware is which — established by what each one *does*, because there is nothing else to
|
|
66
|
+
* go on.
|
|
67
|
+
*
|
|
68
|
+
* `workspaceScoped`, `requiresCapability` and `requires` all come back from oRPC as a function
|
|
69
|
+
* named `decorated` carrying identical own properties: no name, no tag, nothing to compare. So each
|
|
70
|
+
* middleware is called here against a kernel stub that records what it reached for, and what it
|
|
71
|
+
* reached for is its identity.
|
|
72
|
+
*
|
|
73
|
+
* This is what replaced `middlewares.length >= 2`, which was satisfied by *any* two middlewares —
|
|
74
|
+
* a procedure that had lost `workspaceScoped` and kept two permission checks passed it, and that is
|
|
75
|
+
* precisely the failure this file's docblock claims to prevent. A count is not an assertion about
|
|
76
|
+
* authorisation; it is an assertion about arithmetic.
|
|
77
|
+
*/
|
|
78
|
+
interface Reached {
|
|
79
|
+
/** the module id `workspaceScoped` asked `isModuleEnabled` about */
|
|
80
|
+
module?: string
|
|
81
|
+
/** the permission `requires` asked `authz.require` for */
|
|
82
|
+
permission?: string
|
|
83
|
+
/** the `<module>.<capability>` `requiresCapability` looked up */
|
|
84
|
+
capability?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type MiddlewareFn = (
|
|
88
|
+
options: { context: unknown; next: (options?: unknown) => Promise<unknown> },
|
|
89
|
+
input: unknown,
|
|
90
|
+
) => Promise<unknown>
|
|
91
|
+
|
|
92
|
+
const WORKSPACE = '00000000-0000-4000-8000-000000000000'
|
|
93
|
+
|
|
94
|
+
async function reachedFor(middleware: unknown): Promise<Reached> {
|
|
95
|
+
const seen: Reached = {}
|
|
96
|
+
const kernel = {
|
|
97
|
+
authz: {
|
|
98
|
+
requireMember: () => undefined,
|
|
99
|
+
require: async (_principal: unknown, permission: string) => {
|
|
100
|
+
seen.permission = permission
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
isModuleEnabled: async (_workspaceId: string, moduleId: string) => {
|
|
104
|
+
seen.module = moduleId
|
|
105
|
+
return true
|
|
106
|
+
},
|
|
107
|
+
// The real one answers a Set; a stub only has to record which capability was asked about.
|
|
108
|
+
capabilities: async (_workspaceId: string, moduleId: string) => ({
|
|
109
|
+
has: (capability: string) => {
|
|
110
|
+
seen.capability = `${moduleId}.${capability}`
|
|
111
|
+
return true
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
}
|
|
115
|
+
const principal = { kind: 'user', userId: WORKSPACE, instanceAdmin: false, memberships: [] }
|
|
116
|
+
await (middleware as MiddlewareFn)(
|
|
117
|
+
{ context: { kernel, principal }, next: async () => ({}) },
|
|
118
|
+
{ workspaceId: WORKSPACE },
|
|
119
|
+
)
|
|
120
|
+
return seen
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const chainOf = (name: string): unknown[] => implemented[name]?.['~orpc'].middlewares ?? []
|
|
124
|
+
|
|
57
125
|
describe('every procedure is authorised', () => {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
126
|
+
const declaredKeys = new Set(inventoryPermissions.map((p) => p.key))
|
|
127
|
+
|
|
128
|
+
it('puts the workspace and module gate first, on every procedure', async () => {
|
|
129
|
+
for (const name of Object.keys(implemented)) {
|
|
130
|
+
const [first] = chainOf(name)
|
|
131
|
+
expect(
|
|
132
|
+
first === undefined ? undefined : (await reachedFor(first)).module,
|
|
133
|
+
`${name}: the first middleware has to be workspaceScoped('${MODULE_ID}') — a real membership, and the module switched on for that workspace`,
|
|
134
|
+
).toBe(MODULE_ID)
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('puts a permission check the module declares after it', async () => {
|
|
139
|
+
for (const name of Object.keys(implemented)) {
|
|
140
|
+
const asked = (await Promise.all(chainOf(name).slice(1).map(reachedFor)))
|
|
141
|
+
.map((r) => r.permission)
|
|
142
|
+
.filter((p) => p !== undefined)
|
|
143
|
+
expect(
|
|
144
|
+
asked.filter((p) => declaredKeys.has(p)),
|
|
145
|
+
`${name}: needs requires(<a permission this module declares>) after the workspace gate; it asked for ${JSON.stringify(asked)}`,
|
|
146
|
+
).not.toHaveLength(0)
|
|
62
147
|
}
|
|
63
148
|
})
|
|
64
149
|
})
|
|
@@ -74,6 +159,54 @@ describe('the module declares what it uses', () => {
|
|
|
74
159
|
expect(inventoryModule.definition.id).toBe(MODULE_ID)
|
|
75
160
|
expect(inventoryModule.definition.permissions).toBe(inventoryPermissions)
|
|
76
161
|
expect(inventoryModule.definition.events).toBe(inventoryEvents)
|
|
162
|
+
expect(inventoryModule.definition.capabilities).toBe(inventoryCapabilities)
|
|
77
163
|
expect(inventoryModule.router, 'a module with a contract has to mount a router').toBeTypeOf('function')
|
|
78
164
|
})
|
|
79
165
|
})
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Capabilities, which are the one thing here that cannot be seen by reading a handler.
|
|
169
|
+
*
|
|
170
|
+
* A missing `requiresCapability` is invisible: the procedure compiles, every other test passes, and
|
|
171
|
+
* the only symptom is a workspace successfully calling a feature it switched off. So the map is
|
|
172
|
+
* declared as data in the contract and checked against the router here.
|
|
173
|
+
*
|
|
174
|
+
* The map is empty while `core` is the only capability — `core` is `required`, so nothing sits
|
|
175
|
+
* behind a switch anyone can flip. These tests are what keep that true as the map fills up.
|
|
176
|
+
*/
|
|
177
|
+
describe('capabilities are enforced where they are declared', () => {
|
|
178
|
+
const gated = new Set(Object.values(inventoryCapabilityProcedures).flat())
|
|
179
|
+
|
|
180
|
+
it('names only capabilities the module actually declares', () => {
|
|
181
|
+
const declaredIds = new Set(inventoryCapabilities.map((c) => c.id))
|
|
182
|
+
for (const id of Object.keys(inventoryCapabilityProcedures))
|
|
183
|
+
expect({ id, declared: declaredIds.has(id) }).toEqual({ id, declared: true })
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('names only procedures the contract actually has', () => {
|
|
187
|
+
for (const name of gated) expect({ name, exists: name in declared }).toEqual({ name, exists: true })
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('gates every procedure that belongs to a capability on that capability', async () => {
|
|
191
|
+
// Identity again, not a count: a third middleware proves nothing about which capability — or
|
|
192
|
+
// whether a capability — is being checked. `workspaceScoped` -> `requiresCapability` ->
|
|
193
|
+
// `requires`, in that order, so a workspace with the whole module off is refused before
|
|
194
|
+
// anything reveals which capabilities it would have had.
|
|
195
|
+
for (const [capability, names] of Object.entries(inventoryCapabilityProcedures)) {
|
|
196
|
+
for (const name of names) {
|
|
197
|
+
const chain = await Promise.all(chainOf(name).map(reachedFor))
|
|
198
|
+
const gate = chain.findIndex((r) => r.capability === `${MODULE_ID}.${capability}`)
|
|
199
|
+
const permission = chain.findIndex((r) => r.permission !== undefined)
|
|
200
|
+
expect(gate, `${name}: needs requiresCapability('${MODULE_ID}', '${capability}')`).toBeGreaterThan(0)
|
|
201
|
+
expect(gate, `${name}: the capability gate belongs before the permission check`).toBeLessThan(
|
|
202
|
+
permission,
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('leaves `core` off the map, because a required capability is not a switch', () => {
|
|
209
|
+
expect(inventoryCapabilityProcedures.core).toBeUndefined()
|
|
210
|
+
expect(inventoryCapabilities.find((c) => c.id === 'core')?.required).toBe(true)
|
|
211
|
+
})
|
|
212
|
+
})
|
package/src/server/index.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { dirname, join } from 'node:path'
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
InventorySettings,
|
|
5
|
+
inventoryCapabilities,
|
|
6
|
+
inventoryContract,
|
|
7
|
+
inventoryEvents,
|
|
8
|
+
inventoryPermissions,
|
|
9
|
+
MODULE_ID,
|
|
10
|
+
} from '../contract/index.js'
|
|
11
|
+
import { defineModule, defineServerModule, inventoryRouter, packageVersion } from './router.js'
|
|
5
12
|
import { schema } from './schema.js'
|
|
6
13
|
|
|
7
14
|
export const inventoryModule = defineServerModule({
|
|
@@ -10,24 +17,20 @@ export const inventoryModule = defineServerModule({
|
|
|
10
17
|
name: 'Inventory',
|
|
11
18
|
version: packageVersion(import.meta.url),
|
|
12
19
|
description:
|
|
13
|
-
'The asset register: what the company owns,
|
|
20
|
+
'The asset register: what the company owns, item by item — tags, serial numbers, purchase and warranty details',
|
|
14
21
|
icon: 'briefcase',
|
|
15
22
|
permissions: inventoryPermissions,
|
|
23
|
+
capabilities: inventoryCapabilities,
|
|
16
24
|
events: inventoryEvents,
|
|
25
|
+
settings: InventorySettings,
|
|
26
|
+
// `objectTypes` returns with the resolver that turns a mention or a link into an asset and the
|
|
27
|
+
// indexer that puts one in search — declaring the type with neither made both resolve to
|
|
28
|
+
// nothing, which reads to a user as a broken link rather than as a feature not built yet.
|
|
17
29
|
}),
|
|
18
30
|
/** Attached so the developer panel can check the router against what was promised. */
|
|
19
31
|
contract: inventoryContract,
|
|
20
32
|
schema,
|
|
21
33
|
migrationsFolder: join(dirname(fileURLToPath(import.meta.url)), '../../migrations'),
|
|
22
|
-
router:
|
|
23
|
-
/**
|
|
24
|
-
* What this module reacts to. The pattern may be an exact name, `module.*`, or `*`; handlers are
|
|
25
|
-
* durable consumers in production, so one that throws is retried rather than lost.
|
|
26
|
-
*/
|
|
27
|
-
subscriptions: {
|
|
28
|
-
'core.workspace.created': async (event, kernel) => {
|
|
29
|
-
kernel.log.info({ module: MODULE_ID, event: event.name }, 'a workspace was created')
|
|
30
|
-
},
|
|
31
|
-
},
|
|
34
|
+
router: inventoryRouter,
|
|
32
35
|
})
|
|
33
36
|
export default inventoryModule
|