@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.
Files changed (103) hide show
  1. package/README.md +55 -9
  2. package/dist/contract/capabilities.d.ts +49 -0
  3. package/dist/contract/capabilities.d.ts.map +1 -0
  4. package/dist/contract/capabilities.js +52 -0
  5. package/dist/contract/capabilities.js.map +1 -0
  6. package/dist/contract/events.d.ts +33 -0
  7. package/dist/contract/events.d.ts.map +1 -0
  8. package/dist/contract/events.js +22 -0
  9. package/dist/contract/events.js.map +1 -0
  10. package/dist/contract/index.d.ts +15 -0
  11. package/dist/contract/index.d.ts.map +1 -0
  12. package/dist/contract/index.js +15 -0
  13. package/dist/contract/index.js.map +1 -0
  14. package/dist/contract/models.d.ts +146 -0
  15. package/dist/contract/models.d.ts.map +1 -0
  16. package/dist/contract/models.js +107 -0
  17. package/dist/contract/models.js.map +1 -0
  18. package/dist/contract/permissions.d.ts +22 -0
  19. package/dist/contract/permissions.d.ts.map +1 -0
  20. package/dist/contract/permissions.js +26 -0
  21. package/dist/contract/permissions.js.map +1 -0
  22. package/dist/{contract.d.ts → contract/router.d.ts} +24 -85
  23. package/dist/contract/router.d.ts.map +1 -0
  24. package/dist/contract/router.js +42 -0
  25. package/dist/contract/router.js.map +1 -0
  26. package/dist/contract/settings.d.ts +18 -0
  27. package/dist/contract/settings.d.ts.map +1 -0
  28. package/dist/contract/settings.js +29 -0
  29. package/dist/contract/settings.js.map +1 -0
  30. package/dist/server/index.d.ts +4 -1
  31. package/dist/server/index.d.ts.map +1 -1
  32. package/dist/server/index.js +9 -13
  33. package/dist/server/index.js.map +1 -1
  34. package/dist/server/{_impl.d.ts → router.d.ts} +33 -8
  35. package/dist/server/{_impl.d.ts.map → router.d.ts.map} +1 -1
  36. package/dist/server/router.js +83 -0
  37. package/dist/server/router.js.map +1 -0
  38. package/dist/server/schema.d.ts +25 -11
  39. package/dist/server/schema.d.ts.map +1 -1
  40. package/dist/server/schema.js +28 -10
  41. package/dist/server/schema.js.map +1 -1
  42. package/dist/server/services/assets.d.ts +73 -0
  43. package/dist/server/services/assets.d.ts.map +1 -0
  44. package/dist/server/services/assets.js +261 -0
  45. package/dist/server/services/assets.js.map +1 -0
  46. package/dist/server/services/index.d.ts +10 -0
  47. package/dist/server/services/index.d.ts.map +1 -0
  48. package/dist/server/services/index.js +15 -0
  49. package/dist/server/services/index.js.map +1 -0
  50. package/dist/server/services/notify.d.ts +63 -0
  51. package/dist/server/services/notify.d.ts.map +1 -0
  52. package/dist/server/services/notify.js +105 -0
  53. package/dist/server/services/notify.js.map +1 -0
  54. package/migrations/0000_init.sql +12 -3
  55. package/migrations/0001_rls.sql +24 -0
  56. package/migrations/meta/0000_snapshot.json +40 -13
  57. package/migrations/meta/_journal.json +2 -2
  58. package/package.json +12 -9
  59. package/src/client/api.ts +1 -1
  60. package/src/client/components/AssetFormDialog.svelte +130 -47
  61. package/src/client/i18n.ts +11 -166
  62. package/src/client/index.ts +8 -1
  63. package/src/client/messages.test.ts +169 -0
  64. package/src/client/messages.ts +399 -0
  65. package/src/client/mock.test.ts +161 -0
  66. package/src/client/mock.ts +267 -45
  67. package/src/client/module.ts +22 -2
  68. package/src/client/pages/AssetsPage.svelte +358 -137
  69. package/src/client/permissions.ts +1 -1
  70. package/src/client/price.test.ts +106 -0
  71. package/src/client/price.ts +135 -0
  72. package/src/client/query.test.ts +58 -0
  73. package/src/client/query.ts +15 -2
  74. package/src/client/settings/GeneralSettings.svelte +0 -0
  75. package/src/client/settings/core-api.ts +32 -0
  76. package/src/client/widgets/OverviewWidget.svelte +16 -3
  77. package/src/contract/capabilities.ts +55 -0
  78. package/src/contract/events.ts +34 -0
  79. package/src/contract/index.ts +15 -0
  80. package/src/contract/models.ts +123 -0
  81. package/src/contract/permissions.ts +26 -0
  82. package/src/contract/router.ts +46 -0
  83. package/src/contract/settings.ts +30 -0
  84. package/src/module.test.ts +140 -7
  85. package/src/server/index.ts +16 -13
  86. package/src/server/inventory.int.test.ts +819 -0
  87. package/src/server/migrations.test.ts +138 -0
  88. package/src/server/router.ts +118 -0
  89. package/src/server/schema.ts +27 -10
  90. package/src/server/services/assets.ts +368 -0
  91. package/src/server/services/index.ts +23 -0
  92. package/src/server/services/notify.ts +151 -0
  93. package/tsconfig.base.json +22 -0
  94. package/tsconfig.client.json +1 -1
  95. package/tsconfig.json +1 -1
  96. package/vitest.config.ts +18 -3
  97. package/dist/contract.d.ts.map +0 -1
  98. package/dist/contract.js +0 -119
  99. package/dist/contract.js.map +0 -1
  100. package/dist/server/_impl.js +0 -204
  101. package/dist/server/_impl.js.map +0 -1
  102. package/src/contract.ts +0 -143
  103. 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>
@@ -13,9 +13,16 @@
13
13
  */
14
14
  import type { Kernel } from '@kernhq/kernel'
15
15
  import { describe, expect, it } from 'vitest'
16
- import { inventoryContract, inventoryEvents, inventoryPermissions, MODULE_ID } from './contract.js'
17
- import { implement_ } from './server/_impl.js'
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(implement_({} as Kernel))
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
- it('carries both the workspace/module gate and a permission check', () => {
59
- for (const [name, leaf] of Object.entries(implemented)) {
60
- // `workspaceScoped(MODULE_ID)` + `requires('<permission>')`
61
- expect(leaf['~orpc'].middlewares?.length ?? 0, `${name} middlewares`).toBeGreaterThanOrEqual(2)
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
+ })
@@ -1,7 +1,14 @@
1
1
  import { dirname, join } from 'node:path'
2
2
  import { fileURLToPath } from 'node:url'
3
- import { inventoryContract, inventoryEvents, inventoryPermissions, MODULE_ID } from '../contract.js'
4
- import { defineModule, defineServerModule, implement_, packageVersion } from './_impl.js'
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, who holds each item, and everything that happened to it',
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: implement_,
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