@owlmeans/server-planning 0.1.18-rc.2 → 0.1.18-rc.21

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 (217) hide show
  1. package/README.md +40 -12
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/server-planning/SKILL.md +193 -18
  4. package/build/actions/cards.d.ts +3 -5
  5. package/build/actions/cards.d.ts.map +1 -1
  6. package/build/actions/cards.js +1 -1
  7. package/build/actions/cards.js.map +1 -1
  8. package/build/actions/execute.d.ts +24 -3
  9. package/build/actions/execute.d.ts.map +1 -1
  10. package/build/actions/execute.js +82 -8
  11. package/build/actions/execute.js.map +1 -1
  12. package/build/actions/schemas.d.ts +27 -5
  13. package/build/actions/schemas.d.ts.map +1 -1
  14. package/build/actions/schemas.js +67 -7
  15. package/build/actions/schemas.js.map +1 -1
  16. package/build/actions/specs.d.ts +2 -4
  17. package/build/actions/specs.d.ts.map +1 -1
  18. package/build/actions/specs.js.map +1 -1
  19. package/build/actions/types.d.ts +4 -0
  20. package/build/actions/types.d.ts.map +1 -0
  21. package/build/actions/types.js +2 -0
  22. package/build/actions/types.js.map +1 -0
  23. package/build/actions/watch.d.ts.map +1 -1
  24. package/build/actions/watch.js +4 -2
  25. package/build/actions/watch.js.map +1 -1
  26. package/build/conformance/assert.d.ts +20 -0
  27. package/build/conformance/assert.d.ts.map +1 -0
  28. package/build/conformance/assert.js +54 -0
  29. package/build/conformance/assert.js.map +1 -0
  30. package/build/conformance/cases.d.ts +7 -0
  31. package/build/conformance/cases.d.ts.map +1 -0
  32. package/build/conformance/cases.js +447 -0
  33. package/build/conformance/cases.js.map +1 -0
  34. package/build/conformance/consts.d.ts +19 -0
  35. package/build/conformance/consts.d.ts.map +1 -0
  36. package/build/conformance/consts.js +42 -0
  37. package/build/conformance/consts.js.map +1 -0
  38. package/build/conformance/fixtures.d.ts +14 -0
  39. package/build/conformance/fixtures.d.ts.map +1 -0
  40. package/build/conformance/fixtures.js +109 -0
  41. package/build/conformance/fixtures.js.map +1 -0
  42. package/build/conformance/index.d.ts +12 -0
  43. package/build/conformance/index.d.ts.map +1 -0
  44. package/build/conformance/index.js +11 -0
  45. package/build/conformance/index.js.map +1 -0
  46. package/build/conformance/types.d.ts +18 -0
  47. package/build/conformance/types.d.ts.map +1 -0
  48. package/build/conformance/types.js +2 -0
  49. package/build/conformance/types.js.map +1 -0
  50. package/build/consts.d.ts +5 -0
  51. package/build/consts.d.ts.map +1 -1
  52. package/build/consts.js +5 -0
  53. package/build/consts.js.map +1 -1
  54. package/build/definitions.d.ts +17 -0
  55. package/build/definitions.d.ts.map +1 -0
  56. package/build/definitions.js +211 -0
  57. package/build/definitions.js.map +1 -0
  58. package/build/executor/changes.d.ts +2 -2
  59. package/build/executor/changes.d.ts.map +1 -1
  60. package/build/executor/changes.js.map +1 -1
  61. package/build/executor/code.d.ts +3 -3
  62. package/build/executor/code.d.ts.map +1 -1
  63. package/build/executor/code.js +1 -1
  64. package/build/executor/code.js.map +1 -1
  65. package/build/executor/consts.local.d.ts +5 -0
  66. package/build/executor/consts.local.d.ts.map +1 -0
  67. package/build/executor/consts.local.js +6 -0
  68. package/build/executor/consts.local.js.map +1 -0
  69. package/build/executor/creator.d.ts +30 -0
  70. package/build/executor/creator.d.ts.map +1 -0
  71. package/build/executor/creator.js +46 -0
  72. package/build/executor/creator.js.map +1 -0
  73. package/build/executor/resolve.d.ts +31 -23
  74. package/build/executor/resolve.d.ts.map +1 -1
  75. package/build/executor/resolve.js +74 -15
  76. package/build/executor/resolve.js.map +1 -1
  77. package/build/executor/types.d.ts +24 -0
  78. package/build/executor/types.d.ts.map +1 -0
  79. package/build/executor/types.js +2 -0
  80. package/build/executor/types.js.map +1 -0
  81. package/build/executor/validate.d.ts +6 -5
  82. package/build/executor/validate.d.ts.map +1 -1
  83. package/build/executor/validate.js +18 -15
  84. package/build/executor/validate.js.map +1 -1
  85. package/build/executor.d.ts +10 -7
  86. package/build/executor.d.ts.map +1 -1
  87. package/build/executor.js +17 -14
  88. package/build/executor.js.map +1 -1
  89. package/build/facade.d.ts +20 -2
  90. package/build/facade.d.ts.map +1 -1
  91. package/build/facade.js +128 -18
  92. package/build/facade.js.map +1 -1
  93. package/build/helper.d.ts +3 -2
  94. package/build/helper.d.ts.map +1 -1
  95. package/build/helper.js +7 -3
  96. package/build/helper.js.map +1 -1
  97. package/build/index.d.ts +1 -0
  98. package/build/index.d.ts.map +1 -1
  99. package/build/index.js +1 -0
  100. package/build/index.js.map +1 -1
  101. package/build/projection.d.ts +2 -10
  102. package/build/projection.d.ts.map +1 -1
  103. package/build/projection.js +1 -1
  104. package/build/projection.js.map +1 -1
  105. package/build/queue/consts.d.ts +5 -0
  106. package/build/queue/consts.d.ts.map +1 -0
  107. package/build/queue/consts.js +5 -0
  108. package/build/queue/consts.js.map +1 -0
  109. package/build/queue/index.d.ts +4 -16
  110. package/build/queue/index.d.ts.map +1 -1
  111. package/build/queue/index.js +2 -4
  112. package/build/queue/index.js.map +1 -1
  113. package/build/queue/types.d.ts +13 -0
  114. package/build/queue/types.d.ts.map +1 -0
  115. package/build/queue/types.js +2 -0
  116. package/build/queue/types.js.map +1 -0
  117. package/build/registry.d.ts +2 -21
  118. package/build/registry.d.ts.map +1 -1
  119. package/build/registry.js +3 -1
  120. package/build/registry.js.map +1 -1
  121. package/build/service.d.ts +3 -16
  122. package/build/service.d.ts.map +1 -1
  123. package/build/service.js +9 -0
  124. package/build/service.js.map +1 -1
  125. package/build/store/commits.d.ts.map +1 -1
  126. package/build/store/commits.js +3 -1
  127. package/build/store/commits.js.map +1 -1
  128. package/build/store/composite.d.ts +1 -0
  129. package/build/store/composite.d.ts.map +1 -1
  130. package/build/store/composite.js +3 -0
  131. package/build/store/composite.js.map +1 -1
  132. package/build/store/consts.local.d.ts +2 -0
  133. package/build/store/consts.local.d.ts.map +1 -0
  134. package/build/store/consts.local.js +2 -0
  135. package/build/store/consts.local.js.map +1 -0
  136. package/build/store/fold.d.ts +4 -3
  137. package/build/store/fold.d.ts.map +1 -1
  138. package/build/store/fold.js +14 -6
  139. package/build/store/fold.js.map +1 -1
  140. package/build/store/memory.d.ts +12 -0
  141. package/build/store/memory.d.ts.map +1 -1
  142. package/build/store/memory.js +0 -0
  143. package/build/store/memory.js.map +1 -1
  144. package/build/store/types.d.ts +15 -1
  145. package/build/store/types.d.ts.map +1 -1
  146. package/build/store/types.local.d.ts +3 -0
  147. package/build/store/types.local.d.ts.map +1 -0
  148. package/build/store/types.local.js +2 -0
  149. package/build/store/types.local.js.map +1 -0
  150. package/build/types.d.ts +111 -1
  151. package/build/types.d.ts.map +1 -1
  152. package/build/types.local.d.ts +7 -0
  153. package/build/types.local.d.ts.map +1 -0
  154. package/build/types.local.js +2 -0
  155. package/build/types.local.js.map +1 -0
  156. package/build/utils/index.d.ts +1 -0
  157. package/build/utils/index.d.ts.map +1 -1
  158. package/build/utils/scope.d.ts +39 -2
  159. package/build/utils/scope.d.ts.map +1 -1
  160. package/build/utils/scope.js +79 -15
  161. package/build/utils/scope.js.map +1 -1
  162. package/build/utils/types.d.ts +8 -0
  163. package/build/utils/types.d.ts.map +1 -0
  164. package/build/utils/types.js +2 -0
  165. package/build/utils/types.js.map +1 -0
  166. package/package.json +28 -20
  167. package/src/actions/cards.ts +2 -6
  168. package/src/actions/execute.ts +95 -12
  169. package/src/actions/schemas.ts +81 -9
  170. package/src/actions/specs.ts +1 -2
  171. package/src/actions/types.ts +4 -0
  172. package/src/actions/watch.ts +5 -2
  173. package/src/conformance/assert.ts +59 -0
  174. package/src/conformance/cases.ts +522 -0
  175. package/src/conformance/consts.ts +44 -0
  176. package/src/conformance/fixtures.ts +121 -0
  177. package/src/conformance/index.ts +11 -0
  178. package/src/conformance/types.ts +20 -0
  179. package/src/consts.ts +6 -0
  180. package/src/definitions.ts +250 -0
  181. package/src/executor/changes.ts +2 -6
  182. package/src/executor/code.ts +3 -9
  183. package/src/executor/consts.local.ts +8 -0
  184. package/src/executor/creator.ts +51 -0
  185. package/src/executor/resolve.ts +84 -40
  186. package/src/executor/types.ts +25 -0
  187. package/src/executor/validate.ts +22 -26
  188. package/src/executor.ts +21 -21
  189. package/src/facade.ts +143 -25
  190. package/src/helper.ts +7 -3
  191. package/src/index.ts +1 -0
  192. package/src/projection.ts +4 -14
  193. package/src/queue/consts.ts +5 -0
  194. package/src/queue/index.ts +6 -21
  195. package/src/queue/types.ts +14 -0
  196. package/src/registry.ts +5 -29
  197. package/src/service.ts +13 -20
  198. package/src/store/commits.ts +5 -4
  199. package/src/store/composite.ts +3 -0
  200. package/src/store/consts.local.ts +1 -0
  201. package/src/store/fold.ts +15 -12
  202. package/src/store/memory.ts +0 -0
  203. package/src/store/types.local.ts +3 -0
  204. package/src/store/types.ts +16 -1
  205. package/src/types.local.ts +7 -0
  206. package/src/types.ts +126 -4
  207. package/src/utils/index.ts +1 -0
  208. package/src/utils/scope.ts +97 -17
  209. package/src/utils/types.ts +8 -0
  210. package/tests/access.spec.ts +160 -0
  211. package/tests/conformance.spec.ts +47 -0
  212. package/tests/create-example.spec.ts +202 -0
  213. package/tests/creator.spec.ts +257 -0
  214. package/tests/entrypoints.spec.ts +5 -0
  215. package/tests/narrowing.spec.ts +94 -0
  216. package/tests/plugins.spec.ts +4 -3
  217. package/tests/writes.spec.ts +140 -0
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  CommitEvent, CommitSource, CommitStatus, PlanningStore, ProjectionStore, Relationship,
3
- RelationshipStore, SpecificationStore, Transition, TransitionStore, Workcard,
3
+ RelationshipStore, SchemaStore, ScopedSchemaRecord, SpecificationStore, Transition, TransitionStore,
4
+ Workcard,
4
5
  } from '@owlmeans/planning'
5
6
 
6
7
  /** What a folding store calls for every committed transition — the service's `committed`. */
@@ -49,6 +50,11 @@ export interface FoldOptions {
49
50
  limit?: number
50
51
  /** Called between transitions — a queue processor renews its lock here. */
51
52
  touch?: () => Promise<void>
53
+ /**
54
+ * Runs one transition's store writes as a unit — a transactional store opens a savepoint here, so
55
+ * a write the database refuses leaves nothing of that transition behind and the fold goes past it.
56
+ */
57
+ unit?: <R>(run: () => Promise<R>) => Promise<R>
52
58
  }
53
59
 
54
60
  export interface FoldResult {
@@ -63,6 +69,8 @@ export interface MemoryPlanningStoreSeed {
63
69
  cards?: Workcard[]
64
70
  links?: Relationship[]
65
71
  transitions?: Transition[]
72
+ /** Data-defined type and flow records — kept only by a store built with `schemas: true`. */
73
+ schemas?: ScopedSchemaRecord[]
66
74
  }
67
75
 
68
76
  export interface MemoryPlanningStoreOptions {
@@ -74,6 +82,11 @@ export interface MemoryPlanningStoreOptions {
74
82
  /** The commit listener used until a service binds its own. */
75
83
  onCommitted?: CommitListener
76
84
  alias?: string
85
+ /**
86
+ * Hold data-defined types and flows (the `schemas` port). Off by default: a store without it
87
+ * resolves every type and flow from the code registry, exactly as before.
88
+ */
89
+ schemas?: boolean
77
90
  }
78
91
 
79
92
  export interface MemoryPlanningStore extends BindablePlanningStore {
@@ -82,6 +95,8 @@ export interface MemoryPlanningStore extends BindablePlanningStore {
82
95
  specs: SpecificationStore
83
96
  links: RelationshipStore
84
97
  commits: CommitHub
98
+ /** Present when the store was built with `schemas: true`. */
99
+ schemas?: SchemaStore
85
100
  bind: (committed: CommitListener) => void
86
101
  /** Fold what is pending — for one card, or for every card with a pending transition. */
87
102
  flush: (card?: string) => Promise<void>
@@ -0,0 +1,7 @@
1
+ import { PlanningSchemaKind, type StatusFlowSchema, type WorkcardTypeSchema } from '@owlmeans/planning'
2
+
3
+ export interface Planned {
4
+ kind: PlanningSchemaKind
5
+ key: string
6
+ definition: WorkcardTypeSchema | StatusFlowSchema
7
+ }
package/src/types.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  import type { BasicConfig, BasicContext, LazyService } from '@owlmeans/context'
2
2
  import type { AbstractRequest } from '@owlmeans/entrypoint'
3
- import type {
4
- AnyTypeSchema, PlanningPlugin, PlanningScope, PlanningService, PlanningStore, StatusFlowSchema,
5
- WithPlanningService,
6
- } from '@owlmeans/planning'
3
+ import type { AnyTypeSchema, PlanningPlugin, PlanningScope, PlanningService, PlanningStore, StatusFlowSchema, WithPlanningService, PlanningSchemaRegistry, SchemaStore, ScopedSchemaRegistry, CommitEvent, PlanningExecContext, PlanningHookContext, TransitionExecution, WorkcardDraft } from '@owlmeans/planning'
7
4
  import type { ApiServerAppend } from '@owlmeans/server-api'
8
5
  import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
6
+ import type { StoreRoute } from './store/types.js'
7
+
9
8
 
10
9
  export interface Config extends ServerConfig { }
11
10
 
@@ -37,6 +36,51 @@ export interface PlanningScopeExtractor {
37
36
  (req: AbstractRequest, ctx: BasicContext<BasicConfig>): Partial<PlanningScope> | Promise<Partial<PlanningScope>>
38
37
  }
39
38
 
39
+ /**
40
+ * A grant: `true` for everything the request can see, a list for those project cards only (the
41
+ * parent of a created project, the deleted project, the schema layer).
42
+ */
43
+ export type PlanningGrant = boolean | string[]
44
+
45
+ export interface PlanningAccessGrants {
46
+ /** Create a project — at the root only with `true`; a list names the parent projects. */
47
+ createProjects?: PlanningGrant
48
+ /** Delete a project — a list names the projects. */
49
+ deleteProjects?: PlanningGrant
50
+ /** Write data-defined types and flows — the organization-wide layer only with `true`. */
51
+ defineSchemas?: PlanningGrant
52
+ }
53
+
54
+ /** What a hosting application decides about one request. */
55
+ export interface PlanningAccess {
56
+ /** The organization the request acts in — the only source of it once a resolver is given. */
57
+ entityId: string
58
+ /** Narrows every read and write to these project cards. Every project when omitted. */
59
+ projects?: string[]
60
+ /**
61
+ * The project cards the request may WRITE in: `execute` on a card, specification or link inside
62
+ * one of them (the same rule a narrowed read admits — the project, the cards whose `parents` name
63
+ * it, a specification through its parent card), and `schema.define` of a project layer. A project
64
+ * outside `projects` is still unreachable. Omitted — writes reach every project `projects` admits;
65
+ * present — anything else is refused with `PlanningForbidden` (403). Creating a project is
66
+ * `grants.createProjects`' alone; the organization-wide schema layer is `grants.defineSchemas`'.
67
+ */
68
+ writes?: string[]
69
+ /**
70
+ * Gates the writes above. Omitted — nothing is gated; present — a flag it leaves out is refused
71
+ * with `PlanningForbidden`.
72
+ */
73
+ grants?: PlanningAccessGrants
74
+ }
75
+
76
+ /**
77
+ * Resolves a request's access. A throw is the request's answer (an `AuthForbidden` answers 403
78
+ * like any other guard refusal).
79
+ */
80
+ export interface PlanningAccessResolver {
81
+ (req: AbstractRequest, ctx: BasicContext<BasicConfig>): Promise<PlanningAccess>
82
+ }
83
+
40
84
  export interface PlanningHandlerOptions {
41
85
  /** The planning service alias. `PLANNING_SERVICE` when omitted. */
42
86
  service?: string
@@ -45,4 +89,82 @@ export interface PlanningHandlerOptions {
45
89
  /** The longest single long poll, in seconds — clamps `commit.get`'s `wait` and `execute`'s `timeout`. */
46
90
  maxPoll?: number
47
91
  scope?: PlanningScopeExtractor
92
+ /**
93
+ * The hosting application's access decision per request. Without it the organization is the
94
+ * request's own (`requireEntityKey`) and nothing is narrowed or gated.
95
+ */
96
+ access?: PlanningAccessResolver
97
+ }
98
+
99
+ export interface SchemaViewsOptions {
100
+ /** The code registry every layer starts from. */
101
+ code: () => PlanningSchemaRegistry
102
+ /** Bumps whenever the code registry changes (a plugin's `use`). */
103
+ version: () => number
104
+ /** The schema port, when the store has one. */
105
+ port: () => SchemaStore | undefined
106
+ /** How many resolved layers are kept. */
107
+ limit?: number
108
+ }
109
+
110
+ export interface SchemaViews {
111
+ /** The code registry itself without a port; the resolved layer with one. */
112
+ of: (entityId: string, project?: string) => Promise<PlanningSchemaRegistry>
113
+ /** @throws {PlanningUnsupported} without a port */
114
+ scoped: (entityId: string, project?: string) => Promise<ScopedSchemaRegistry>
115
+ }
116
+
117
+ export interface ProjectionOptions {
118
+ /** The planning service alias. */
119
+ service?: string
120
+ /** The store the queue folds — the service's default store when omitted. */
121
+ store?: (service: PlanningService) => PlanningStore
122
+ /** Where events go — the store's commit hub `publish` when it has one. */
123
+ publish?: (event: CommitEvent) => Promise<void>
124
+ }
125
+
126
+ export interface PluginRegistry {
127
+ /** Register, or replace the plugin of the same `name`. Its schemas are contributed now. */
128
+ use: (plugin: PlanningPlugin) => void
129
+ /** Every plugin, `order` ascending (registration order among equals). */
130
+ plugins: () => PlanningPlugin[]
131
+ /** The owning plugins that supply a store, in plugin order, each store resolved once. */
132
+ routes: (ctx?: BasicContext<BasicConfig>) => StoreRoute[]
133
+ /** The first owning plugin's store for a type, else `fallback`. */
134
+ storeFor: (type: string | undefined, fallback: PlanningStore, ctx?: BasicContext<BasicConfig>) => PlanningStore
135
+ /** The first plugin answering a code wins. */
136
+ mintCode: (
137
+ draft: WorkcardDraft, taken: (code: string) => Promise<boolean>, contextOf: (plugin: PlanningPlugin) => PlanningExecContext
138
+ ) => Promise<string | undefined>
139
+ /** The `before` chain; answers the execution the chain left. */
140
+ before: (
141
+ exec: TransitionExecution, contextOf: (plugin: PlanningPlugin) => PlanningExecContext
142
+ ) => Promise<TransitionExecution>
143
+ /** The `after` chain; a failing hook is logged and the chain goes on. */
144
+ after: (event: CommitEvent, contextOf: (plugin: PlanningPlugin) => PlanningHookContext) => Promise<void>
145
+ /** Bumps on every `use` — what a cached composite store is keyed by. */
146
+ version: () => number
147
+ }
148
+
149
+ export type PlanningServiceApi = Pick<PlanningService, 'use' | 'plugins' | 'schemas' | 'store' | 'for' | 'committed'>
150
+
151
+ /** What the facade and the executor reach the service through. */
152
+ export interface PlanningRuntime {
153
+ service: () => PlanningService
154
+ registry: PluginRegistry
155
+ options: PlanningServiceOptions
156
+ context: () => BasicContext<BasicConfig> | undefined
157
+ /** The composite store reads and commits go through. */
158
+ reader: () => PlanningStore
159
+ /** Every distinct store: the default one first, then each plugin's. */
160
+ stores: () => PlanningStore[]
161
+ /** The default store's data-defined schema port, when it has one. */
162
+ schemaStore: () => SchemaStore | undefined
163
+ /**
164
+ * The registry a card of this organization (and project) resolves through. Without a schema
165
+ * port it is the service's code registry itself — the very same object.
166
+ */
167
+ schemasFor: (entityId: string, project?: string) => Promise<PlanningSchemaRegistry>
168
+ /** The resolved layer. @throws {PlanningUnsupported} without a schema port */
169
+ scopedSchemas: (entityId: string, project?: string) => Promise<ScopedSchemaRegistry>
48
170
  }
@@ -1,2 +1,3 @@
1
1
  export * from './guard.js'
2
2
  export * from './scope.js'
3
+ export type * from './types.js'
@@ -1,9 +1,11 @@
1
1
  import { requireEntityKey } from '@owlmeans/auth-common'
2
2
  import type { BasicConfig, BasicContext } from '@owlmeans/context'
3
3
  import type { AbstractRequest } from '@owlmeans/entrypoint'
4
- import { PLANNING_SERVICE } from '@owlmeans/planning'
5
- import type { PlanningFacade, PlanningScope, PlanningService, TransitionActor } from '@owlmeans/planning'
6
- import type { PlanningHandlerOptions, PlanningHostService } from '../types.js'
4
+ import { PLANNING_SERVICE, PlanningForbidden, type PlanningFacade, type PlanningScope, type PlanningService, type TransitionActor, type Workcard } from '@owlmeans/planning'
5
+ import type {
6
+ PlanningAccess, PlanningAccessGrants, PlanningHandlerOptions, PlanningHostService,
7
+ } from '../types.js'
8
+ import type { HandlerScope } from './types.js'
7
9
 
8
10
  const clean = <T extends object>(record: T): T =>
9
11
  Object.fromEntries(Object.entries(record).filter(([, value]) => value != null && value !== '')) as T
@@ -14,17 +16,7 @@ export const actorOf = (req: AbstractRequest): TransitionActor => clean({
14
16
  userId: req.auth?.userId,
15
17
  })
16
18
 
17
- /**
18
- * The scope a request reads and writes as.
19
- *
20
- * `entityId` is `requireEntityKey(req)` — the resolved organization id, never a value from the
21
- * body or the query — and `extra` cannot replace it; `extra` adds what the deployment derives
22
- * (a `channel`, a `service`). The actor is the authenticated subject plus that channel.
23
- *
24
- * @throws {AuthorizationError} when the request carries no organization
25
- */
26
- export const scopeOf = (req: AbstractRequest, extra?: Partial<PlanningScope>): PlanningScope => {
27
- const entityId = requireEntityKey(req)
19
+ const scopeWith = (req: AbstractRequest, entityId: string, extra?: Partial<PlanningScope>): PlanningScope => {
28
20
  const subject = actorOf(req)
29
21
  const channel = extra?.channel ?? extra?.actor?.channel
30
22
 
@@ -37,15 +29,103 @@ export const scopeOf = (req: AbstractRequest, extra?: Partial<PlanningScope>): P
37
29
  }) as PlanningScope
38
30
  }
39
31
 
32
+ /**
33
+ * The scope a request reads and writes as.
34
+ *
35
+ * `entityId` is `requireEntityKey(req)` — the resolved organization id, never a value from the
36
+ * body or the query — and `extra` cannot replace it; `extra` adds what the deployment derives
37
+ * (a `channel`, a `service`). The actor is the authenticated subject plus that channel.
38
+ *
39
+ * @throws {AuthorizationError} when the request carries no organization
40
+ */
41
+ export const scopeOf = (req: AbstractRequest, extra?: Partial<PlanningScope>): PlanningScope =>
42
+ scopeWith(req, requireEntityKey(req), extra)
43
+
44
+ /**
45
+ * The scope of a request under a resolved {@link PlanningAccess}: the organization is the
46
+ * resolver's — never the token's, never `extra`'s — and `projects` narrows it.
47
+ */
48
+ export const accessScopeOf = (
49
+ req: AbstractRequest, access: PlanningAccess, extra?: Partial<PlanningScope>
50
+ ): PlanningScope => {
51
+ const { projects: _projects, entityId: _entityId, ...rest } = extra ?? {}
52
+ const scope = scopeWith(req, access.entityId, rest)
53
+
54
+ return access.projects != null ? { ...scope, projects: [...access.projects] } : scope
55
+ }
56
+
40
57
  export const planningServiceOf = (
41
58
  ctx: BasicContext<BasicConfig>, opts?: Pick<PlanningHandlerOptions, 'service'>
42
59
  ): PlanningService => ctx.service<PlanningHostService>(opts?.service ?? PLANNING_SERVICE)
43
60
 
61
+ /**
62
+ * The facade a handler works through, and the access it was resolved under.
63
+ *
64
+ * Without `opts.access` it is exactly the request's scope plus what `opts.scope` derives. With it,
65
+ * the resolver names the organization and the visible projects; a throw from the resolver is the
66
+ * request's error.
67
+ */
68
+ export const handlerScopeOf = async (
69
+ ctx: BasicContext<BasicConfig>, req: AbstractRequest, opts?: PlanningHandlerOptions
70
+ ): Promise<HandlerScope> => {
71
+ const extra = await opts?.scope?.(req, ctx)
72
+ if (opts?.access == null) {
73
+ return { facade: planningServiceOf(ctx, opts).for(scopeOf(req, extra ?? undefined)) }
74
+ }
75
+ const access = await opts.access(req, ctx)
76
+
77
+ return { facade: planningServiceOf(ctx, opts).for(accessScopeOf(req, access, extra ?? undefined)), access }
78
+ }
79
+
44
80
  /** The facade a handler works through: the request's scope plus what `opts.scope` derives. */
45
81
  export const handlerFacade = async (
46
82
  ctx: BasicContext<BasicConfig>, req: AbstractRequest, opts?: PlanningHandlerOptions
47
- ): Promise<PlanningFacade> => {
48
- const extra = await opts?.scope?.(req, ctx)
83
+ ): Promise<PlanningFacade> => (await handlerScopeOf(ctx, req, opts)).facade
84
+
85
+ /**
86
+ * Refuse a write the access does not grant. No access, or access without `grants`, gates nothing;
87
+ * a `grants` object refuses every flag it leaves out. `true` grants everything; a list grants the
88
+ * project ids it names — `target` is the project the write is about (`undefined` for the
89
+ * organization's root, which only `true` reaches).
90
+ *
91
+ * @throws {PlanningForbidden}
92
+ */
93
+ export const assertGranted = (
94
+ access: PlanningAccess | undefined, grant: keyof PlanningAccessGrants, target?: string
95
+ ): void => {
96
+ if (access?.grants == null) {
97
+ return
98
+ }
99
+ const granted = access.grants[grant]
100
+ if (granted === true || (Array.isArray(granted) && target != null && granted.includes(target))) {
101
+ return
102
+ }
103
+ throw new PlanningForbidden(`${grant}:${target ?? 'root'}`)
104
+ }
49
105
 
50
- return planningServiceOf(ctx, opts).for(scopeOf(req, extra ?? undefined))
106
+ /**
107
+ * A card the access's `writes` admits — the rule a narrowed read admits, over `writes`: a project
108
+ * it names, or a card whose `parents` name one. A specification is admitted through its parent card
109
+ * by the caller. No `writes` admits everything.
110
+ */
111
+ export const writableIn = (access: PlanningAccess | undefined, card: Pick<Workcard, 'id' | 'parents'>): boolean => {
112
+ const writes = access?.writes
113
+ if (writes == null) {
114
+ return true
115
+ }
116
+ return (card.id != null && writes.includes(card.id)) || card.parents.some(parent => writes.includes(parent))
117
+ }
118
+
119
+ /**
120
+ * Refuse a write into a project the access's `writes` leaves out. No `writes` refuses nothing;
121
+ * `target` is the project the write is in (`undefined` for the organization's root, which a set
122
+ * `writes` never admits).
123
+ *
124
+ * @throws {PlanningForbidden}
125
+ */
126
+ export const assertWrites = (access: PlanningAccess | undefined, target?: string): void => {
127
+ if (access?.writes == null || (target != null && access.writes.includes(target))) {
128
+ return
129
+ }
130
+ throw new PlanningForbidden(`writes:${target ?? 'root'}`)
51
131
  }
@@ -0,0 +1,8 @@
1
+ import type { PlanningFacade } from '@owlmeans/planning'
2
+ import type { PlanningAccess } from '../types.js'
3
+
4
+ export interface HandlerScope {
5
+ facade: PlanningFacade
6
+ /** The resolver's answer, when the handler options carry one. */
7
+ access?: PlanningAccess
8
+ }
@@ -0,0 +1,160 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { AuthForbidden } from '@owlmeans/auth'
3
+ import {
4
+ CommitState, makePlanningProtocols, ParentNotFound, PlanningForbidden, PlanningSchemaKind, SchemaOrigin,
5
+ TransitionAction, WorkcardKind, WorkcardNotFound,
6
+ } from '@owlmeans/planning'
7
+ import type { CommitEvent, SchemaDefineReply, ScopedSchemaBundle } from '@owlmeans/planning'
8
+ import {
9
+ defineSchemas, executePlanning, getCard, getCommit, listCards, listLinks, listSchemas, summarizeCards,
10
+ } from '../src/actions/index.js'
11
+ import type { PlanningAccess, PlanningHandlerOptions } from '../src/types.js'
12
+ import {
13
+ createProject, createTask, ENTITY, invoke, makeTestPlanning, OTHER_ENTITY, PROJECT, session, TASK,
14
+ } from './context.js'
15
+
16
+ const tree = makePlanningProtocols({ base: { alias: 'test:planning' }, guards: 'test-guard', definitions: true })
17
+
18
+ const withAccess = (access: PlanningAccess | (() => PlanningAccess)): PlanningHandlerOptions => ({
19
+ access: async () => typeof access === 'function' ? access() : access,
20
+ })
21
+
22
+ describe('@owlmeans/server-planning — access resolver', () => {
23
+ test('the organization is the resolver\'s, never the token\'s, and a resolver throw is the answer', async () => {
24
+ const { context, facade } = await makeTestPlanning()
25
+ const theirs = await createProject(facade(OTHER_ENTITY), 'Theirs')
26
+ await createProject(facade(), 'Mine')
27
+
28
+ const listed = await invoke(listCards(tree.card.list, withAccess({ entityId: OTHER_ENTITY })), context, { ...session(ENTITY), query: {} })
29
+ expect(listed.items.map((card: { id: string }) => card.id)).toEqual([theirs.id])
30
+
31
+ const refusing: PlanningHandlerOptions = { access: async () => { throw new AuthForbidden('no-seat') } }
32
+ await expect(invoke(listCards(tree.card.list, refusing), context, { ...session(), query: {} })).rejects.toBeInstanceOf(AuthForbidden)
33
+ })
34
+
35
+ test('projects narrow reads: an invisible project and its cards are absent, lists and summaries shrink', async () => {
36
+ const { context, facade } = await makeTestPlanning()
37
+ const visible = await createProject(facade(), 'Visible')
38
+ const hidden = await createProject(facade(), 'Hidden')
39
+ const seen = (await createTask(facade(), visible.id!, 'Seen')).card!
40
+ const unseen = (await createTask(facade(), hidden.id!, 'Unseen')).card!
41
+ await facade().execute({ card: unseen.id!, action: TransitionAction.Link, link: { type: 'blocks', to: seen.id! } }, { wait: true })
42
+ const opts = withAccess({ entityId: ENTITY, projects: [visible.id!] })
43
+
44
+ await expect(invoke(getCard(tree.card.get, opts), context, { ...session(), params: { id: unseen.id } })).rejects.toBeInstanceOf(WorkcardNotFound)
45
+ await expect(invoke(getCard(tree.card.get, opts), context, { ...session(), params: { id: hidden.id } })).rejects.toBeInstanceOf(WorkcardNotFound)
46
+ expect((await invoke(getCard(tree.card.get, opts), context, { ...session(), params: { id: seen.id } })).id).toBe(seen.id)
47
+
48
+ const listed = await invoke(listCards(tree.card.list, opts), context, { ...session(), query: { type: TASK } })
49
+ expect(listed.items.map((card: { id: string }) => card.id)).toEqual([seen.id])
50
+ const summary = await invoke(summarizeCards(tree.card.summary, opts), context, { ...session(), query: { parents: `${visible.id},${hidden.id}` } })
51
+ expect(Object.keys(summary)).toEqual([visible.id!])
52
+ expect((await invoke(listLinks(tree.link.list, opts), context, { ...session(), query: { to: seen.id } })).total).toBe(0)
53
+ })
54
+
55
+ test('projects narrow writes: an invisible parent is ParentNotFound, an invisible card absent', async () => {
56
+ const { context, facade } = await makeTestPlanning()
57
+ const visible = await createProject(facade(), 'Visible')
58
+ const hidden = await createProject(facade(), 'Hidden')
59
+ const unseen = (await createTask(facade(), hidden.id!, 'Unseen')).card!
60
+ const execute = executePlanning(tree.execute, withAccess({ entityId: ENTITY, projects: [visible.id!] }))
61
+
62
+ await expect(invoke(execute, context, {
63
+ ...session(), body: { card: { kind: WorkcardKind.Card, type: TASK, parent: hidden.id, title: 'Sneaky' }, action: TransitionAction.Create },
64
+ })).rejects.toBeInstanceOf(ParentNotFound)
65
+ await expect(invoke(execute, context, {
66
+ ...session(), body: { card: unseen.id, action: TransitionAction.Update, changes: { title: 'Renamed' } },
67
+ })).rejects.toBeInstanceOf(WorkcardNotFound)
68
+
69
+ const made = await invoke(execute, context, {
70
+ ...session(), body: { card: { kind: WorkcardKind.Card, type: TASK, parent: visible.id, title: 'Allowed' }, action: TransitionAction.Create, wait: true },
71
+ })
72
+ expect(made.card.title).toBe('Allowed')
73
+ expect((await facade().cards.get(unseen.id!)).title).toBe('Unseen')
74
+ })
75
+
76
+ test('commit status and subscriptions stay inside the visible projects', async () => {
77
+ const { context, facade, service } = await makeTestPlanning()
78
+ const visible = await createProject(facade(), 'Visible')
79
+ const hidden = await createProject(facade(), 'Hidden')
80
+ const narrowed = service.for({ entityId: ENTITY, projects: [visible.id!] })
81
+ const heard: CommitEvent[] = []
82
+ const unsubscribe = await narrowed.commits.subscribe(event => { heard.push(event) })
83
+
84
+ const seen = await createTask(facade(), visible.id!, 'Seen')
85
+ const unseen = await createTask(facade(), hidden.id!, 'Unseen')
86
+ unsubscribe()
87
+
88
+ expect(heard.map(event => event.card)).toEqual([seen.card!.id!])
89
+ await expect(invoke(getCommit(tree.commit.get, withAccess({ entityId: ENTITY, projects: [visible.id!] })), context, {
90
+ ...session(), params: { transition: unseen.transition.id }, query: {},
91
+ })).rejects.toBeInstanceOf(WorkcardNotFound)
92
+ expect((await narrowed.commits.status(seen.transition.id!)).state).toBe(CommitState.Committed)
93
+ })
94
+
95
+ test('grants gate project creation, project deletion and schema definitions with a 403 refusal', async () => {
96
+ const { context, facade } = await makeTestPlanning({}, { schemas: true })
97
+ const parent = await createProject(facade(), 'Parent')
98
+ const other = await createProject(facade(), 'Other')
99
+ const nested = { kind: WorkcardKind.Project, type: PROJECT, parent: parent.id, title: 'Nested' }
100
+ const scoped = (grants: PlanningAccess['grants']) => withAccess({ entityId: ENTITY, grants })
101
+
102
+ await expect(invoke(executePlanning(tree.execute, scoped({})), context, {
103
+ ...session(), body: { card: { kind: WorkcardKind.Project, type: PROJECT, title: 'Root' }, action: TransitionAction.Create },
104
+ })).rejects.toBeInstanceOf(PlanningForbidden)
105
+ expect(PlanningForbidden.httpStatus).toBe(403)
106
+ await expect(invoke(executePlanning(tree.execute, scoped({ createProjects: [parent.id!] })), context, {
107
+ ...session(), body: { card: { kind: WorkcardKind.Project, type: PROJECT, title: 'Root' }, action: TransitionAction.Create },
108
+ })).rejects.toBeInstanceOf(PlanningForbidden)
109
+ const made = await invoke(executePlanning(tree.execute, scoped({ createProjects: [parent.id!] })), context, {
110
+ ...session(), body: { card: nested, action: TransitionAction.Create, wait: true },
111
+ })
112
+ expect(made.card.parent).toBe(parent.id)
113
+ // A card is not a project: creating one needs no grant.
114
+ await invoke(executePlanning(tree.execute, scoped({})), context, {
115
+ ...session(), body: { card: { kind: WorkcardKind.Card, type: TASK, parent: parent.id, title: 'Task' }, action: TransitionAction.Create },
116
+ })
117
+
118
+ await expect(invoke(executePlanning(tree.execute, scoped({ deleteProjects: [other.id!] })), context, {
119
+ ...session(), body: { card: parent.id, action: TransitionAction.Delete },
120
+ })).rejects.toBeInstanceOf(PlanningForbidden)
121
+ await invoke(executePlanning(tree.execute, scoped({ deleteProjects: [other.id!] })), context, {
122
+ ...session(), body: { card: other.id, action: TransitionAction.Delete, wait: true },
123
+ })
124
+ expect(await facade().cards.load(other.id!)).toBeNull()
125
+
126
+ const flow = { id: 'test:rota', version: 1, statuses: [{ key: 'due', intrinsic: 'planned' }], transitions: [] }
127
+ await expect(invoke(defineSchemas(tree.schema.define!, scoped({ defineSchemas: [parent.id!] })), context, {
128
+ ...session(), body: { flows: [flow] },
129
+ })).rejects.toBeInstanceOf(PlanningForbidden)
130
+ const layer: SchemaDefineReply = await invoke(defineSchemas(tree.schema.define!, scoped({ defineSchemas: [parent.id!] })), context, {
131
+ ...session(), body: { project: parent.id, flows: [flow] },
132
+ })
133
+ expect([layer.bundle.origins?.flows['test:rota'], layer.records.map(record => record.key)]).toEqual([SchemaOrigin.Project, ['test:rota']])
134
+ })
135
+ })
136
+
137
+ describe('@owlmeans/server-planning — schema handlers', () => {
138
+ test('schema.list answers the code bundle without a schema port, and the scoped layer with one', async () => {
139
+ const plain = await makeTestPlanning()
140
+ const bundle = await invoke(listSchemas(tree.schema.list), plain.context, { ...session(), query: {} })
141
+ expect(bundle).toEqual(plain.service.schemas.bundle())
142
+
143
+ const scoped = await makeTestPlanning({}, { schemas: true })
144
+ const project = await createProject(scoped.facade(), 'Layered')
145
+ const note = { type: 'test:memo', kind: WorkcardKind.Card, version: 1, fields: { type: 'object' }, flows: ['test:task'], specifications: [] }
146
+ await invoke(defineSchemas(tree.schema.define!), scoped.context, { ...session(), body: { project: project.id, types: [note] } })
147
+
148
+ const entityLayer: ScopedSchemaBundle = await invoke(listSchemas(tree.schema.list), scoped.context, { ...session(), query: {} })
149
+ const projectLayer: ScopedSchemaBundle = await invoke(listSchemas(tree.schema.list), scoped.context, { ...session(), query: { project: project.id } })
150
+ expect(entityLayer.types.some(type => type.type === 'test:memo')).toBe(false)
151
+ expect(projectLayer.origins?.types['test:memo']).toBe(SchemaOrigin.Project)
152
+ await expect(invoke(listSchemas(tree.schema.list), scoped.context, { ...session(OTHER_ENTITY), query: { project: project.id } }))
153
+ .rejects.toBeInstanceOf(WorkcardNotFound)
154
+
155
+ const retired: SchemaDefineReply = await invoke(defineSchemas(tree.schema.define!), scoped.context, {
156
+ ...session(), body: { project: project.id, retire: [{ kind: PlanningSchemaKind.Type, key: 'test:memo' }] },
157
+ })
158
+ expect([retired.bundle.retired?.types, retired.records[0]?.retired]).toEqual([['test:memo'], true])
159
+ })
160
+ })
@@ -0,0 +1,47 @@
1
+ import { describe, test } from 'bun:test'
2
+ import { AppType, makeBasicContext } from '@owlmeans/context'
3
+ import type { BasicConfig } from '@owlmeans/context'
4
+ import { appendPlanningService } from '../src/service.js'
5
+ import { makeMemoryPlanningStore } from '../src/store/memory.js'
6
+ import {
7
+ conformanceCasesFor, conformanceClock, planningConformance, planningConformancePlugin,
8
+ } from '../src/conformance/index.js'
9
+ import type { ConformanceSubject } from '../src/conformance/index.js'
10
+
11
+ /** The memory store, booted the way a backend boots it, answering every conformance case. */
12
+ const boot = async (schemas: boolean): Promise<ConformanceSubject> => {
13
+ const now = conformanceClock()
14
+ const store = makeMemoryPlanningStore({ now, schemas })
15
+ const cfg: BasicConfig = { ready: false, service: 'planning-conformance', type: AppType.Backend, services: {} }
16
+ const context = appendPlanningService(makeBasicContext(cfg), { store, now, plugins: [planningConformancePlugin] })
17
+ context.configure()
18
+ await context.init()
19
+ const service = context.planning()
20
+
21
+ return {
22
+ service,
23
+ store,
24
+ facade: entityId => service.for({ entityId, profileId: 'conformance-librarian', channel: 'conformance' }),
25
+ }
26
+ }
27
+
28
+ describe('@owlmeans/server-planning — store conformance (memory, with data-defined schemas)', () => {
29
+ const probe = makeMemoryPlanningStore({ schemas: true })
30
+ for (const entry of conformanceCasesFor(probe)) {
31
+ test(entry.name, async () => { await entry.run(await boot(true)) })
32
+ }
33
+ })
34
+
35
+ describe('@owlmeans/server-planning — store conformance (memory, code schemas only)', () => {
36
+ const probe = makeMemoryPlanningStore()
37
+ const cases = conformanceCasesFor(probe)
38
+ test('a store without the schema port qualifies for every case but the data-defined ones', () => {
39
+ const skipped = planningConformance.filter(entry => !cases.includes(entry))
40
+ if (skipped.some(entry => !(entry.needs ?? []).includes('schemas')) || skipped.length === 0) {
41
+ throw new Error('only the data-defined cases are skipped')
42
+ }
43
+ })
44
+ for (const entry of cases) {
45
+ test(entry.name, async () => { await entry.run(await boot(false)) })
46
+ }
47
+ })