@toa.io/extensions.exposition 1.0.0-alpha.287 → 1.0.0-alpha.289

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 (277) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/components/identity.basic/tsconfig.tsbuildinfo +1 -1
  3. package/components/identity.clients/tsconfig.tsbuildinfo +1 -1
  4. package/components/identity.credentials/tsconfig.tsbuildinfo +1 -1
  5. package/components/identity.federation/tsconfig.tsbuildinfo +1 -1
  6. package/components/identity.grants/tsconfig.tsbuildinfo +1 -1
  7. package/components/identity.keys/tsconfig.tsbuildinfo +1 -1
  8. package/components/identity.otp/tsconfig.tsbuildinfo +1 -1
  9. package/components/identity.passkeys/manifest.toa.yaml +2 -1
  10. package/components/identity.passkeys/source/errors.test.ts +34 -0
  11. package/components/identity.passkeys/source/webauthn.test.ts +26 -0
  12. package/components/identity.passkeys/tsconfig.tsbuildinfo +1 -1
  13. package/components/identity.passkeys/types/toa.d.ts +1 -1
  14. package/components/identity.roles/tsconfig.tsbuildinfo +1 -1
  15. package/components/identity.tokens/tsconfig.tsbuildinfo +1 -1
  16. package/documentation/access.md +23 -4
  17. package/documentation/discovery.md +81 -0
  18. package/documentation/help.md +122 -0
  19. package/documentation/introspection.md +42 -9
  20. package/documentation/mcp.md +13 -16
  21. package/documentation/octets.md +20 -0
  22. package/features/cors.feature +31 -1
  23. package/features/dev.feature +2 -0
  24. package/features/discovery.feature +213 -0
  25. package/features/discovery.ui.feature +213 -0
  26. package/features/help.feature +512 -0
  27. package/features/introspection.feature +94 -8
  28. package/features/map.feature +1 -0
  29. package/features/mcp.feature +74 -12
  30. package/features/methods.feature +2 -1
  31. package/features/oauth.grants.feature +3 -1
  32. package/features/octets.download.feature +1 -0
  33. package/features/octets.feature +59 -0
  34. package/features/octets.meta.feature +1 -0
  35. package/features/site/_app/immutable/asset.js +1 -0
  36. package/features/site/favicon.ico +0 -0
  37. package/features/site/index.html +10 -0
  38. package/features/steps/Components.ts +4 -2
  39. package/features/steps/Database.ts +7 -1
  40. package/features/steps/Gateway.ts +23 -21
  41. package/features/steps/Parameters.ts +37 -16
  42. package/features/steps/Realtime.ts +3 -3
  43. package/package.json +7 -5
  44. package/readme.md +3 -0
  45. package/source/Directive.ts +4 -11
  46. package/source/Discovery/Explorer.ts +34 -0
  47. package/source/Discovery/Site.test.ts +147 -0
  48. package/source/Discovery/Site.ts +202 -0
  49. package/source/Discovery/index.ts +3 -0
  50. package/source/Discovery/tree.test.ts +175 -0
  51. package/source/Discovery/tree.ts +77 -0
  52. package/source/Discovery/trunk.ts +33 -0
  53. package/source/Endpoint.ts +4 -0
  54. package/source/Factory.ts +3 -86
  55. package/source/Gateway.ts +34 -4
  56. package/source/HTTP/Context.ts +7 -0
  57. package/source/HTTP/Probe.ts +0 -11
  58. package/source/HTTP/Server.ts +2 -3
  59. package/source/HTTP/messages.ts +4 -2
  60. package/source/Introspection.ts +58 -7
  61. package/source/MCP/schema.ts +16 -5
  62. package/source/MCP/tools.ts +14 -32
  63. package/source/Mapping.ts +5 -0
  64. package/source/Query.ts +17 -2
  65. package/source/RTD/Directives.ts +10 -0
  66. package/source/RTD/Endpoint.ts +8 -1
  67. package/source/RTD/Node.ts +20 -6
  68. package/source/RTD/Tree.ts +19 -9
  69. package/source/RTD/factory.ts +37 -1
  70. package/source/RTD/segment.ts +21 -0
  71. package/source/const.ts +26 -0
  72. package/source/deployment.ts +2 -2
  73. package/source/directives/auth/Anonymous.test.ts +31 -5
  74. package/source/directives/auth/Anonymous.ts +15 -3
  75. package/source/directives/auth/Anyone.ts +6 -0
  76. package/source/directives/auth/Assert.ts +5 -0
  77. package/source/directives/auth/Delegate.ts +5 -2
  78. package/source/directives/auth/Federation.ts +11 -0
  79. package/source/directives/auth/Id.ts +15 -0
  80. package/source/directives/auth/Input.ts +5 -0
  81. package/source/directives/auth/Role.ts +17 -0
  82. package/source/directives/cors/CORS.test.ts +75 -0
  83. package/source/directives/cors/CORS.ts +10 -3
  84. package/source/directives/help/Family.ts +122 -0
  85. package/source/directives/help/Help.test.ts +227 -0
  86. package/source/directives/help/Help.ts +47 -0
  87. package/source/directives/help/Parameters.ts +47 -0
  88. package/source/directives/help/described.ts +57 -0
  89. package/source/directives/help/index.ts +45 -0
  90. package/source/directives/index.ts +8 -1
  91. package/source/directives/map/Headers.ts +6 -6
  92. package/source/directives/mcp/MCP.ts +12 -21
  93. package/source/directives/mcp/Tool.test.ts +22 -68
  94. package/source/directives/mcp/Tool.ts +14 -41
  95. package/source/directives/octets/Directive.ts +7 -0
  96. package/source/directives/octets/Octets.ts +8 -0
  97. package/source/directives/octets/Put.ts +10 -0
  98. package/source/manifest.ts +1 -1
  99. package/source/root.ts +2 -2
  100. package/source/service.ts +94 -0
  101. package/source/shortcuts.ts +14 -0
  102. package/transpiled/Directive.d.ts +1 -1
  103. package/transpiled/Directive.js +3 -11
  104. package/transpiled/Directive.js.map +1 -1
  105. package/transpiled/Discovery/Explorer.d.ts +11 -0
  106. package/transpiled/Discovery/Explorer.js +29 -0
  107. package/transpiled/Discovery/Explorer.js.map +1 -0
  108. package/transpiled/Discovery/Site.d.ts +43 -0
  109. package/transpiled/Discovery/Site.js +171 -0
  110. package/transpiled/Discovery/Site.js.map +1 -0
  111. package/transpiled/Discovery/index.d.ts +3 -0
  112. package/transpiled/Discovery/index.js +4 -0
  113. package/transpiled/Discovery/index.js.map +1 -0
  114. package/transpiled/Discovery/tree.d.ts +26 -0
  115. package/transpiled/Discovery/tree.js +50 -0
  116. package/transpiled/Discovery/tree.js.map +1 -0
  117. package/transpiled/Discovery/trunk.d.ts +13 -0
  118. package/transpiled/Discovery/trunk.js +29 -0
  119. package/transpiled/Discovery/trunk.js.map +1 -0
  120. package/transpiled/Endpoint.d.ts +2 -1
  121. package/transpiled/Endpoint.js +3 -0
  122. package/transpiled/Endpoint.js.map +1 -1
  123. package/transpiled/Factory.js +3 -61
  124. package/transpiled/Factory.js.map +1 -1
  125. package/transpiled/Gateway.d.ts +4 -0
  126. package/transpiled/Gateway.js +29 -4
  127. package/transpiled/Gateway.js.map +1 -1
  128. package/transpiled/HTTP/Context.d.ts +6 -0
  129. package/transpiled/HTTP/Context.js +6 -0
  130. package/transpiled/HTTP/Context.js.map +1 -1
  131. package/transpiled/HTTP/Probe.d.ts +0 -10
  132. package/transpiled/HTTP/Probe.js +0 -10
  133. package/transpiled/HTTP/Probe.js.map +1 -1
  134. package/transpiled/HTTP/Server.d.ts +0 -1
  135. package/transpiled/HTTP/Server.js +2 -2
  136. package/transpiled/HTTP/Server.js.map +1 -1
  137. package/transpiled/HTTP/messages.js +4 -2
  138. package/transpiled/HTTP/messages.js.map +1 -1
  139. package/transpiled/Introspection.d.ts +38 -6
  140. package/transpiled/Introspection.js +18 -1
  141. package/transpiled/Introspection.js.map +1 -1
  142. package/transpiled/MCP/schema.d.ts +2 -2
  143. package/transpiled/MCP/schema.js +11 -5
  144. package/transpiled/MCP/schema.js.map +1 -1
  145. package/transpiled/MCP/tools.d.ts +1 -1
  146. package/transpiled/MCP/tools.js +12 -28
  147. package/transpiled/MCP/tools.js.map +1 -1
  148. package/transpiled/Mapping.d.ts +2 -0
  149. package/transpiled/Mapping.js +4 -0
  150. package/transpiled/Mapping.js.map +1 -1
  151. package/transpiled/Query.d.ts +12 -0
  152. package/transpiled/Query.js +16 -2
  153. package/transpiled/Query.js.map +1 -1
  154. package/transpiled/RTD/Directives.d.ts +8 -0
  155. package/transpiled/RTD/Endpoint.d.ts +7 -1
  156. package/transpiled/RTD/Node.d.ts +11 -2
  157. package/transpiled/RTD/Node.js +12 -2
  158. package/transpiled/RTD/Node.js.map +1 -1
  159. package/transpiled/RTD/Tree.js +15 -8
  160. package/transpiled/RTD/Tree.js.map +1 -1
  161. package/transpiled/RTD/factory.js +27 -1
  162. package/transpiled/RTD/factory.js.map +1 -1
  163. package/transpiled/RTD/segment.d.ts +7 -0
  164. package/transpiled/RTD/segment.js +17 -0
  165. package/transpiled/RTD/segment.js.map +1 -1
  166. package/transpiled/const.d.ts +21 -0
  167. package/transpiled/const.js +21 -0
  168. package/transpiled/const.js.map +1 -1
  169. package/transpiled/deployment.js +2 -2
  170. package/transpiled/deployment.js.map +1 -1
  171. package/transpiled/directives/auth/Anonymous.d.ts +11 -2
  172. package/transpiled/directives/auth/Anonymous.js +13 -3
  173. package/transpiled/directives/auth/Anonymous.js.map +1 -1
  174. package/transpiled/directives/auth/Anyone.d.ts +3 -0
  175. package/transpiled/directives/auth/Anyone.js +4 -0
  176. package/transpiled/directives/auth/Anyone.js.map +1 -1
  177. package/transpiled/directives/auth/Assert.d.ts +2 -0
  178. package/transpiled/directives/auth/Assert.js +4 -0
  179. package/transpiled/directives/auth/Assert.js.map +1 -1
  180. package/transpiled/directives/auth/Delegate.d.ts +4 -1
  181. package/transpiled/directives/auth/Delegate.js +5 -2
  182. package/transpiled/directives/auth/Delegate.js.map +1 -1
  183. package/transpiled/directives/auth/Federation.d.ts +5 -0
  184. package/transpiled/directives/auth/Federation.js +8 -0
  185. package/transpiled/directives/auth/Federation.js.map +1 -1
  186. package/transpiled/directives/auth/Id.d.ts +9 -0
  187. package/transpiled/directives/auth/Id.js +12 -0
  188. package/transpiled/directives/auth/Id.js.map +1 -1
  189. package/transpiled/directives/auth/Input.d.ts +2 -0
  190. package/transpiled/directives/auth/Input.js +4 -0
  191. package/transpiled/directives/auth/Input.js.map +1 -1
  192. package/transpiled/directives/auth/Role.d.ts +7 -0
  193. package/transpiled/directives/auth/Role.js +11 -0
  194. package/transpiled/directives/auth/Role.js.map +1 -1
  195. package/transpiled/directives/cors/CORS.js +9 -2
  196. package/transpiled/directives/cors/CORS.js.map +1 -1
  197. package/transpiled/directives/help/Family.d.ts +28 -0
  198. package/transpiled/directives/help/Family.js +83 -0
  199. package/transpiled/directives/help/Family.js.map +1 -0
  200. package/transpiled/directives/help/Help.d.ts +22 -0
  201. package/transpiled/directives/help/Help.js +36 -0
  202. package/transpiled/directives/help/Help.js.map +1 -0
  203. package/transpiled/directives/help/Parameters.d.ts +18 -0
  204. package/transpiled/directives/help/Parameters.js +34 -0
  205. package/transpiled/directives/help/Parameters.js.map +1 -0
  206. package/transpiled/directives/help/described.d.ts +16 -0
  207. package/transpiled/directives/help/described.js +23 -0
  208. package/transpiled/directives/help/described.js.map +1 -0
  209. package/transpiled/directives/help/index.d.ts +22 -0
  210. package/transpiled/directives/help/index.js +34 -0
  211. package/transpiled/directives/help/index.js.map +1 -0
  212. package/transpiled/directives/index.d.ts +4 -0
  213. package/transpiled/directives/index.js +8 -1
  214. package/transpiled/directives/index.js.map +1 -1
  215. package/transpiled/directives/map/Headers.d.ts +5 -0
  216. package/transpiled/directives/map/Headers.js +7 -5
  217. package/transpiled/directives/map/Headers.js.map +1 -1
  218. package/transpiled/directives/mcp/MCP.d.ts +8 -5
  219. package/transpiled/directives/mcp/MCP.js +9 -13
  220. package/transpiled/directives/mcp/MCP.js.map +1 -1
  221. package/transpiled/directives/mcp/Tool.d.ts +6 -12
  222. package/transpiled/directives/mcp/Tool.js +10 -23
  223. package/transpiled/directives/mcp/Tool.js.map +1 -1
  224. package/transpiled/directives/octets/Directive.d.ts +6 -0
  225. package/transpiled/directives/octets/Directive.js.map +1 -1
  226. package/transpiled/directives/octets/Octets.d.ts +3 -0
  227. package/transpiled/directives/octets/Octets.js +6 -0
  228. package/transpiled/directives/octets/Octets.js.map +1 -1
  229. package/transpiled/directives/octets/Put.d.ts +3 -0
  230. package/transpiled/directives/octets/Put.js +8 -0
  231. package/transpiled/directives/octets/Put.js.map +1 -1
  232. package/transpiled/manifest.js +1 -1
  233. package/transpiled/root.js +2 -2
  234. package/transpiled/root.js.map +1 -1
  235. package/transpiled/service.d.ts +7 -0
  236. package/transpiled/service.js +74 -0
  237. package/transpiled/service.js.map +1 -0
  238. package/transpiled/shortcuts.d.ts +3 -0
  239. package/transpiled/shortcuts.js +13 -0
  240. package/transpiled/shortcuts.js.map +1 -0
  241. package/tsconfig.tsbuildinfo +1 -1
  242. package/ui/dist/_app/env.js +1 -0
  243. package/ui/dist/_app/immutable/assets/0.Bl7vLmIK.css +2 -0
  244. package/ui/dist/_app/immutable/assets/2.BC0-Jhqu.css +1 -0
  245. package/ui/dist/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
  246. package/ui/dist/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
  247. package/ui/dist/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
  248. package/ui/dist/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
  249. package/ui/dist/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
  250. package/ui/dist/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
  251. package/ui/dist/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
  252. package/ui/dist/_app/immutable/chunks/Bjy-W4x2.js +81 -0
  253. package/ui/dist/_app/immutable/chunks/CDMRPWCx.js +6 -0
  254. package/ui/dist/_app/immutable/chunks/DJTKIUs8.js +1 -0
  255. package/ui/dist/_app/immutable/chunks/DjIYL2zC.js +3 -0
  256. package/ui/dist/_app/immutable/chunks/xihTtKlq.js +1 -0
  257. package/ui/dist/_app/immutable/chunks/z-DVUbQC.js +1 -0
  258. package/ui/dist/_app/immutable/entry/app.5S2Jtv46.js +2 -0
  259. package/ui/dist/_app/immutable/entry/start.Bqi6IBmH.js +1 -0
  260. package/ui/dist/_app/immutable/nodes/0.D4LwNilH.js +1 -0
  261. package/ui/dist/_app/immutable/nodes/1.CSRya9So.js +1 -0
  262. package/ui/dist/_app/immutable/nodes/2.BtiGmR6I.js +14 -0
  263. package/ui/dist/_app/version.json +1 -0
  264. package/ui/dist/apple-touch-icon.png +0 -0
  265. package/ui/dist/favicon-96x96.png +0 -0
  266. package/ui/dist/favicon.ico +0 -0
  267. package/ui/dist/index.html +66 -0
  268. package/components/identity.bans/migrations/0001-system-properties.yaml +0 -14
  269. package/components/identity.basic/migrations/0001-system-properties.yaml +0 -14
  270. package/components/identity.clients/migrations/0001-system-properties.yaml +0 -14
  271. package/components/identity.federation/migrations/0001-system-properties.yaml +0 -14
  272. package/components/identity.grants/migrations/0001-system-properties.yaml +0 -14
  273. package/components/identity.keys/migrations/0001-system-properties.yaml +0 -14
  274. package/components/identity.otp/migrations/0001-system-properties.yaml +0 -14
  275. package/components/identity.passkeys/migrations/0001-system-properties.yaml +0 -14
  276. package/components/identity.roles/migrations/0001-system-properties.yaml +0 -14
  277. package/components/identity.tokens/migrations/0001-system-properties.yaml +0 -14
@@ -29,6 +29,9 @@ export interface Directives {
29
29
  export interface DirectiveFactory {
30
30
  create: (directives: syntax.Directive[], route: string) => Directives
31
31
 
32
+ /** Whether this declaration is carried into the nodes below the one it is on. */
33
+ inheritable: (directive: syntax.Directive) => boolean
34
+
32
35
  /** Runs every family's `preflight`, which is request-scoped and needs no node. */
33
36
  preflight: (context: Context) => Promise<void>
34
37
 
@@ -50,6 +53,13 @@ export interface DirectiveFamily<TDirective = any, TExtension = any> {
50
53
  readonly name: string
51
54
  readonly mandatory: boolean
52
55
 
56
+ /**
57
+ * Whether a declaration applies to everything below the node it is on, as every directive
58
+ * does unless it says otherwise. A family that describes what one node *is* sets this
59
+ * `false`: inherited, it would say the same thing of every resource under it.
60
+ */
61
+ readonly inherited?: boolean
62
+
53
63
  create: (name: string, ...rest: any[]) => TDirective
54
64
 
55
65
  /**
@@ -2,7 +2,7 @@ import { type Context } from './Context.js'
2
2
  import type * as http from '../HTTP/index.js'
3
3
  import type * as syntax from './syntax/index.js'
4
4
  import type * as RTD from './index.js'
5
- import type { Introspection } from '../Introspection.js'
5
+ import type { Introspection, Schema } from '../Introspection.js'
6
6
 
7
7
  export interface Endpoint {
8
8
  call: (
@@ -12,6 +12,13 @@ export interface Endpoint {
12
12
 
13
13
  explain: (parameters: RTD.Parameter[]) => Promise<Introspection>
14
14
 
15
+ /**
16
+ * What picks the records a call is about — `criteria`, `sort` and the rest. Not part of
17
+ * what the resource says about itself: it is the same of every queryable one. A
18
+ * procedure carries it, because there it is something the caller sends.
19
+ */
20
+ selection: () => Record<string, Schema> | null
21
+
15
22
  close: () => Promise<void>
16
23
  }
17
24
 
@@ -3,6 +3,7 @@ import { type Method, type Methods } from './Method.js'
3
3
  import { type Match, type Parameter } from './Match.js'
4
4
  import type { Segment } from './segment.js'
5
5
  import type { Context } from '../HTTP/index.js'
6
+ import { guarded, resource, type Described } from '../directives/help/index.js'
6
7
  import type { Introspection } from '../Introspection.js'
7
8
 
8
9
  export class Node {
@@ -72,11 +73,11 @@ export class Node {
72
73
  for (const route of this.routes) yield* route.walk(segments)
73
74
  }
74
75
 
75
- /** Every method of this node that this caller may reach, in the order they were declared. */
76
- public async explain(
77
- context: Context,
78
- parameters: Parameter[]
79
- ): Promise<Record<string, Introspection>> {
76
+ /**
77
+ * What this resource is, and every method of it this caller may reach, in the order they
78
+ * were declared. The two are answered apart because only the methods are `Allow`.
79
+ */
80
+ public async explain(context: Context, parameters: Parameter[]): Promise<Explained> {
80
81
  const entries = Object.entries(this.methods)
81
82
 
82
83
  const explained = await Promise.all(
@@ -88,7 +89,14 @@ export class Node {
88
89
  for (let i = 0; i < entries.length; i++)
89
90
  if (explained[i] !== null) methods[entries[i][0]] = explained[i]!
90
91
 
91
- return methods
92
+ // every method of a node carries the same declaration, so the first that is there says it
93
+ const stated = entries.length === 0 ? null : resource(entries[0][1].directives)
94
+ const described = { ...stated, ...guarded(methods) }
95
+
96
+ return {
97
+ described: Object.keys(described).length === 0 ? null : described,
98
+ methods
99
+ }
92
100
  }
93
101
 
94
102
  private replace(node: Node): Node[] {
@@ -141,6 +149,12 @@ export class Node {
141
149
  }
142
150
  }
143
151
 
152
+ /** What a resource says of itself, and what it serves. */
153
+ export interface Explained {
154
+ described: Described | null
155
+ methods: Record<string, Introspection>
156
+ }
157
+
144
158
  /** A method, and the route template it is reached by. */
145
159
  export interface Mount {
146
160
  segments: Segment[]
@@ -1,5 +1,6 @@
1
1
  import { console } from 'openspan'
2
2
  import { refusal, template } from '../RPC/names.js'
3
+ import { DISCOVERY } from '../const.js'
3
4
  import { branchTTL, createNode } from './factory.js'
4
5
  import { fragment } from './segment.js'
5
6
  import type { Mount, Node } from './Node.js'
@@ -25,7 +26,7 @@ export class Tree {
25
26
  this.root = node
26
27
  this.trunk = this.createNode(node, PROTECTED)
27
28
 
28
- unnameable(this.trunk)
29
+ announce(this.trunk)
29
30
  }
30
31
 
31
32
  public match(path: string): Match | null {
@@ -48,7 +49,7 @@ export class Tree {
48
49
  public merge(node: syntax.Node, extension: unknown): Node[] {
49
50
  const branch = this.createNode(node, !PROTECTED, extension)
50
51
 
51
- unnameable(branch)
52
+ announce(branch)
52
53
 
53
54
  return this.trunk.merge(branch)
54
55
  }
@@ -87,21 +88,30 @@ export class Tree {
87
88
  }
88
89
 
89
90
  /**
90
- * What is served but cannot be called by name. Said once per route as it is built, because a
91
- * procedure that is missing is otherwise noticed only by the caller who cannot find it.
91
+ * What is served but cannot be reached as it reads. Said once per route as it is built,
92
+ * because either of these is otherwise noticed only by the caller who cannot find it.
92
93
  */
93
- function unnameable(node: Node): void {
94
+ function announce(node: Node): void {
94
95
  const said = new Set<string>()
95
96
 
96
97
  for (const { segments } of node.walk([], TRUNK)) {
97
- const segment = refusal(segments)
98
-
99
- if (segment === null) continue
100
-
101
98
  const route = template(segments)
102
99
 
103
100
  if (said.has(route)) continue
104
101
 
102
+ // the page is served under this prefix, before a request is routed at all
103
+ if (route === DISCOVERY || route.startsWith(DISCOVERY + '/')) {
104
+ said.add(route)
105
+
106
+ console.warn('Route is shadowed by the discovery endpoint', { route })
107
+
108
+ continue
109
+ }
110
+
111
+ const segment = refusal(segments)
112
+
113
+ if (segment === null) continue
114
+
105
115
  said.add(route)
106
116
 
107
117
  console.warn('Route cannot be addressed as a procedure', { route, segment })
@@ -1,7 +1,8 @@
1
+ import assert from 'node:assert'
1
2
  import { BRANCH_TTL } from '../const.js'
2
3
  import { Node, type Properties } from './Node.js'
3
4
  import { Route } from './Route.js'
4
- import { segment } from './segment.js'
5
+ import { fragment, segment } from './segment.js'
5
6
  import { Method, type Methods } from './Method.js'
6
7
  import type { Context } from './Context.js'
7
8
  import type * as syntax from './syntax/index.js'
@@ -10,6 +11,8 @@ export function createNode(node: syntax.Node, context: Context): Node {
10
11
  if (node.isolated === true) context.directives.stack = node.directives
11
12
  else context.directives.stack = node.directives.concat(context.directives.stack)
12
13
 
14
+ described(node, context)
15
+
13
16
  const routes: Route[] = node.routes.map((route) => createRoute(route, context))
14
17
  const methods: Methods = {}
15
18
 
@@ -43,6 +46,16 @@ function createRoute(route: syntax.Route, context: Context): Route {
43
46
 
44
47
  context.path = join(path, route.path)
45
48
 
49
+ /*
50
+ * What a node says of itself is not said of what is under it. Its own `/` is the
51
+ * exception: an intermediate node is never what a path matches, because that route
52
+ * answers in its place — so the two are one resource and what describes it carries.
53
+ */
54
+ if (route.path !== ROOT)
55
+ context.directives.stack = stack.filter((directive) =>
56
+ context.directives.factory.inheritable(directive)
57
+ )
58
+
46
59
  const node = createNode(route.node, context)
47
60
 
48
61
  context.directives.stack = stack // restore
@@ -67,3 +80,26 @@ function createMethod(method: syntax.Method, context: Context): Method {
67
80
 
68
81
  return new Method(endpoint, directives)
69
82
  }
83
+
84
+ /**
85
+ * A resource is what it is described beside: a node with no methods of its own is never
86
+ * what a path answers, so nothing would carry what it says about itself. Refused where the
87
+ * declaration is, rather than left to be noticed by whoever cannot find it in the answer.
88
+ */
89
+ function described(node: syntax.Node, context: Context): void {
90
+ const help = node.directives.find(
91
+ (directive) => directive.family === HELP && directive.name === 'node'
92
+ )
93
+
94
+ if (help === undefined || node.methods.length > 0) return
95
+
96
+ // an intermediate node's `/` answers in its place, and carries what it says
97
+ if (node.routes.some((route) => route.path === ROOT)) return
98
+
99
+ const route = '/' + fragment(context.path).join('/')
100
+
101
+ assert.fail(`Directive help:node: '${route}' serves no methods`)
102
+ }
103
+
104
+ const ROOT = '/'
105
+ const HELP = 'help'
@@ -1,3 +1,5 @@
1
+ import type { Parameter } from './Match.js'
2
+
1
3
  export function segment(path: string): Segment[] {
2
4
  return fragment(path).map(parse)
3
5
  }
@@ -28,3 +30,22 @@ export type Segment =
28
30
  placeholder: string | null
29
31
  wildcard?: boolean
30
32
  }
33
+
34
+ /**
35
+ * What a route template takes, by name. Describing has no values for them — a template is
36
+ * not a path — and nothing that describes a method reads one. A `*` is skipped: it stands
37
+ * for a segment the caller cannot name, so there is nothing to substitute.
38
+ */
39
+ export function variables(segments: Segment[]): Parameter[] {
40
+ const params: Parameter[] = []
41
+
42
+ for (const segment of segments) {
43
+ if (segment.fragment !== null) continue
44
+
45
+ if (segment.wildcard === true) params.push({ name: '**', value: '' })
46
+ else if (segment.placeholder !== null)
47
+ params.push({ name: segment.placeholder, value: '' })
48
+ }
49
+
50
+ return params
51
+ }
package/source/const.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export const BRANCH_TTL = 1_800_000
2
2
 
3
+ /** The broadcast the gateway and its tenants share. */
4
+ export const CHANNEL = 'exposition'
5
+
3
6
  /** The replicas of the gateway decide together, whatever context they serve. */
4
7
  export const ATOM_GROUP = 'exposition'
5
8
 
@@ -17,3 +20,26 @@ export const BATCH = 32
17
20
  * application could choose is a path it could collide with a route of its own.
18
21
  */
19
22
  export const MCP = '/.mcp'
23
+
24
+ /**
25
+ * Where the resource tree and the page that reads it are served. Pinned for the reason
26
+ * `/.rpc` is: a path an application could choose is a path it could collide with a route of
27
+ * its own. Unlike those two it is not annotated — every entry it carries is what `OPTIONS`
28
+ * on that path already answers to the same caller, and what it adds is the enumeration.
29
+ */
30
+ export const DISCOVERY = '/.discovery'
31
+
32
+ /** Where the gateway serves. */
33
+ export const PORT = 8000
34
+
35
+ /**
36
+ * Reserved for the readiness probe. `8001` is the Telemetry readiness probe's, and `toa export`
37
+ * refuses a port claimed twice — `toa mono` and a local run put every service in one process.
38
+ */
39
+ export const PROBE = 8004
40
+
41
+ /**
42
+ * The initial delay of the readiness probe. The server does not sleep for it: whoever
43
+ * probes is the one that waits, and doing it here as well only delayed the process twice.
44
+ */
45
+ export const DELAY = 3 // seconds
@@ -3,10 +3,10 @@ import assert from 'node:assert'
3
3
  import { type Dependency, type Service } from '@toa.io/operations'
4
4
  import { type Annotation } from './Annotation.js'
5
5
  import * as schemas from './schemas.js'
6
- import { shortcuts } from './Directive.js'
6
+ import { shortcuts } from './shortcuts.js'
7
7
  import { components } from './Composition.js'
8
8
  import { parse } from './RTD/syntax/index.js'
9
- import { DELAY, PORT, PROBE } from './HTTP/index.js'
9
+ import { DELAY, PORT, PROBE } from './const.js'
10
10
 
11
11
  /** Where Toa's release publishes this service's image. An application takes it
12
12
  * instead of building one when its context says `registry.services: published`. */
@@ -4,8 +4,16 @@ import assert from 'node:assert/strict'
4
4
  import { Anonymous } from './Anonymous.js'
5
5
  import type { Context } from './types.js'
6
6
 
7
- const context = (headers: Record<string, string>, procedural = false): Context =>
8
- ({ request: { headers }, procedural }) as unknown as Context
7
+ const context = (
8
+ headers: Record<string, string>,
9
+ flags: { procedural?: boolean; exploratory?: boolean } = {}
10
+ ): Context =>
11
+ ({
12
+ request: { headers },
13
+ procedural: false,
14
+ exploratory: false,
15
+ ...flags
16
+ }) as unknown as Context
9
17
 
10
18
  describe('anonymous', () => {
11
19
  it('should admit a request that presents nothing', () => {
@@ -22,14 +30,28 @@ describe('anonymous', () => {
22
30
 
23
31
  it('should admit a procedure whatever the request presented', () => {
24
32
  // what a procedure answers is a value in an envelope, and the envelope is `no-store`
25
- const procedural = context({ authorization: 'Token x' }, true)
33
+ const procedural = context({ authorization: 'Token x' }, { procedural: true })
26
34
 
27
35
  assert.equal(new Anonymous(true).authorize(null, procedural), true)
28
36
  })
29
37
 
38
+ it('should admit a description whatever the request presented', () => {
39
+ // a description is not the reply a cache would hold, and varies by who asked in any case
40
+ const exploratory = context({ authorization: 'Token x' }, { exploratory: true })
41
+
42
+ assert.equal(new Anonymous(true).authorize(null, exploratory), true)
43
+ })
44
+
30
45
  it('should refuse where it admits nobody', () => {
31
46
  assert.equal(new Anonymous(false).authorize(null, context({})), false)
32
- assert.equal(new Anonymous(false).authorize(null, context({}, true)), false)
47
+ assert.equal(
48
+ new Anonymous(false).authorize(null, context({}, { procedural: true })),
49
+ false
50
+ )
51
+ assert.equal(
52
+ new Anonymous(false).authorize(null, context({}, { exploratory: true })),
53
+ false
54
+ )
33
55
  })
34
56
 
35
57
  it('should describe a method as it authorizes one', () => {
@@ -37,7 +59,11 @@ describe('anonymous', () => {
37
59
 
38
60
  assert.equal(directive.admits(null, context({ authorization: 'Token x' })), false)
39
61
  assert.equal(
40
- directive.admits(null, context({ authorization: 'Token x' }, true)),
62
+ directive.admits(null, context({ authorization: 'Token x' }, { procedural: true })),
63
+ true
64
+ )
65
+ assert.equal(
66
+ directive.admits(null, context({ authorization: 'Token x' }, { exploratory: true })),
41
67
  true
42
68
  )
43
69
  })
@@ -1,4 +1,5 @@
1
1
  import { type Directive, type Context } from './types.js'
2
+ import type { Introspection } from '../../Introspection.js'
2
3
 
3
4
  export class Anonymous implements Directive {
4
5
  private readonly allow: boolean
@@ -9,11 +10,13 @@ export class Anonymous implements Directive {
9
10
 
10
11
  /**
11
12
  * A credential refuses, because it would make the reply uncacheable — which is the whole
12
- * of the rule, and none of it applies to a procedure: what a procedure answers is a value
13
- * in an envelope, and the envelope is the one thing that is cached or not.
13
+ * of the rule, and none of it applies to a procedure, nor to a description. What a
14
+ * procedure answers is a value in an envelope, and the envelope is the one thing that is
15
+ * cached or not; what a description answers is what the resource is, which is not the
16
+ * reply a cache would hold and varies by who asked in any case.
14
17
  */
15
18
  public authorize(_: any, context: Context): boolean {
16
- if (context.procedural) return this.allow
19
+ if (context.procedural || context.exploratory) return this.allow
17
20
 
18
21
  return 'authorization' in context.request.headers ? false : this.allow
19
22
  }
@@ -21,4 +24,13 @@ export class Anonymous implements Directive {
21
24
  public admits(_: any, context: Context): boolean {
22
25
  return this.authorize(_, context)
23
26
  }
27
+
28
+ /**
29
+ * That nothing guards it is something said rather than something left unsaid: it is also
30
+ * what refuses a caller presenting a credential, and a client with one has to know which
31
+ * methods not to present it to.
32
+ */
33
+ public describe(introspection: Introspection): Introspection {
34
+ return this.allow ? { ...introspection, anonymous: true } : introspection
35
+ }
24
36
  }
@@ -1,4 +1,5 @@
1
1
  import { type Directive, type Context } from './types.js'
2
+ import type { Introspection } from '../../Introspection.js'
2
3
 
3
4
  export class Anyone implements Directive {
4
5
  private readonly allow: boolean
@@ -14,4 +15,9 @@ export class Anyone implements Directive {
14
15
  public admits(_: any, context: Context): boolean {
15
16
  return this.authorize(_, context)
16
17
  }
18
+
19
+ /** Whoever asks, as long as they are somebody. */
20
+ public describe(introspection: Introspection): Introspection {
21
+ return this.allow ? { ...introspection, authenticated: true } : introspection
22
+ }
17
23
  }
@@ -16,6 +16,11 @@ export class Assert implements Directive {
16
16
  this.disabled = !enabled
17
17
  }
18
18
 
19
+ /** It admits nobody: it is there to require a credential, not to authorize one. */
20
+ public admits(): boolean {
21
+ return false
22
+ }
23
+
19
24
  public async authorize(identity: Identity | null, context: Context): Promise<boolean> {
20
25
  if (!this.disabled) await this.incept(context, identity)
21
26
 
@@ -28,11 +28,14 @@ export class Delegate implements Directive {
28
28
  return identity !== null
29
29
  }
30
30
 
31
- /** The property it embeds is the identity's, so a caller has nothing to put there. */
31
+ /**
32
+ * It takes an identity, and the property it embeds is that identity's — so a caller has
33
+ * nothing to put there.
34
+ */
32
35
  public describe(introspection: Introspection): Introspection {
33
36
  take(introspection, this.property)
34
37
 
35
- return introspection
38
+ return { ...introspection, authenticated: true }
36
39
  }
37
40
 
38
41
  private embed(body: unknown, identity: Identity): Record<string, unknown> {
@@ -1,5 +1,6 @@
1
1
  import assert from 'node:assert'
2
2
  import type { Directive, Identity, Context } from './types.js'
3
+ import type { Introspection } from '../../Introspection.js'
3
4
  import type { Parameter } from '../../RTD/index.js'
4
5
 
5
6
  export class Federation implements Directive {
@@ -16,6 +17,16 @@ export class Federation implements Directive {
16
17
  )
17
18
  }
18
19
 
20
+ /** Which claims it takes needs the request; that it takes an identity does not. */
21
+ public admits(identity: Identity | null): boolean | undefined {
22
+ return identity === null ? false : undefined
23
+ }
24
+
25
+ /** Which claims is the application's business; that it takes an identity is the caller's. */
26
+ public describe(introspection: Introspection): Introspection {
27
+ return { ...introspection, authenticated: true }
28
+ }
29
+
19
30
  public authorize(
20
31
  identity: Identity | null,
21
32
  context: Context,
@@ -1,5 +1,6 @@
1
1
  import { type Parameter } from '../../RTD/index.js'
2
2
  import { type Directive, type Identity } from './types.js'
3
+ import type { Introspection } from '../../Introspection.js'
3
4
 
4
5
  export class Id implements Directive {
5
6
  private readonly parameter: string
@@ -8,6 +9,20 @@ export class Id implements Directive {
8
9
  this.parameter = parameter
9
10
  }
10
11
 
12
+ /**
13
+ * Whether it is *this* identity's cannot be told from a description, which has no route
14
+ * variable to read — but that there must be an identity at all can, and a caller with
15
+ * none is refused whatever the value would have been.
16
+ */
17
+ public admits(identity: Identity | null): boolean | undefined {
18
+ return identity === null ? false : undefined
19
+ }
20
+
21
+ /** Reaching it is being the identity it is about, which is what `private` says. */
22
+ public describe(introspection: Introspection): Introspection {
23
+ return { ...introspection, private: true }
24
+ }
25
+
11
26
  public authorize(
12
27
  identity: Identity | null,
13
28
  _: unknown,
@@ -12,6 +12,11 @@ export class Input implements Directive {
12
12
  )
13
13
  }
14
14
 
15
+ /** It admits nobody: it says what a body may carry, not who may send one. */
16
+ public admits(): boolean {
17
+ return false
18
+ }
19
+
15
20
  public async authorize(
16
21
  identity: Identity | null,
17
22
  context: Context,
@@ -4,6 +4,7 @@ import { type Component } from '@toa.io/core'
4
4
  import type { Query } from '@toa.io/core/types'
5
5
  import { type Directive, type Identity } from './types.js'
6
6
  import type { Parameter } from '../../RTD/index.js'
7
+ import type { Introspection } from '../../Introspection.js'
7
8
 
8
9
  export class Role implements Directive {
9
10
  public static remote: Component | null = null
@@ -31,6 +32,19 @@ export class Role implements Directive {
31
32
  return await this.remote.invoke('list', { query })
32
33
  }
33
34
 
35
+ /**
36
+ * Reaching it takes a role, which is what `protected` says. A role of the `system` scope
37
+ * says so besides: what it guards is the machinery an application runs on rather than
38
+ * anything it serves, and a reader has no business being offered it as one.
39
+ */
40
+ public describe(introspection: Introspection): Introspection {
41
+ const system = this.roles.some(
42
+ (role) => role === SYSTEM || role.startsWith(SYSTEM + ':')
43
+ )
44
+
45
+ return { ...introspection, protected: true, ...(system ? { system: true } : {}) }
46
+ }
47
+
34
48
  public async authorize(
35
49
  identity: Identity | null,
36
50
  _: unknown,
@@ -80,3 +94,6 @@ export class Role implements Directive {
80
94
  )
81
95
  }
82
96
  }
97
+
98
+ /** The scope of what an application runs on, as opposed to what it serves. */
99
+ const SYSTEM = 'system'
@@ -0,0 +1,75 @@
1
+ import { describe, it, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { CORS } from './CORS.js'
5
+ import type { Input } from '../../io.js'
6
+
7
+ const input = (method: string, headers: Record<string, string>): Input =>
8
+ ({
9
+ request: { method, headers },
10
+ pipelines: { body: [], response: [] }
11
+ }) as unknown as Input
12
+
13
+ describe('cors', () => {
14
+ let cors: CORS
15
+
16
+ beforeEach(() => {
17
+ cors = new CORS()
18
+ cors.reset()
19
+ })
20
+
21
+ it('should answer a preflight', () => {
22
+ const output = cors.intercept(
23
+ input('OPTIONS', {
24
+ origin: 'https://hello.world',
25
+ 'access-control-request-method': 'GET'
26
+ })
27
+ )
28
+
29
+ assert.equal(output?.status, 204)
30
+ assert.equal(output?.headers?.get('access-control-allow-origin'), 'https://hello.world')
31
+ })
32
+
33
+ it('should allow OPTIONS, so that one can be preflighted at all', () => {
34
+ const output = cors.intercept(
35
+ input('OPTIONS', {
36
+ origin: 'https://hello.world',
37
+ 'access-control-request-method': 'OPTIONS'
38
+ })
39
+ )
40
+
41
+ assert.match(output?.headers?.get('access-control-allow-methods') ?? '', /\bOPTIONS\b/)
42
+ })
43
+
44
+ it('should pass an OPTIONS that is not a preflight through', () => {
45
+ /*
46
+ * A browser puts `Origin` on every request whose method is not `GET` or `HEAD`, its own
47
+ * `OPTIONS` included — so answering this one here would put introspection out of reach
48
+ * of any page. Do not restore the `Origin`-only test.
49
+ */
50
+ const output = cors.intercept(input('OPTIONS', { origin: 'https://hello.world' }))
51
+
52
+ assert.equal(output, null)
53
+ })
54
+
55
+ it('should decorate a reply that is not a preflight', () => {
56
+ const request = input('OPTIONS', { origin: 'https://hello.world' })
57
+
58
+ cors.intercept(request)
59
+
60
+ const message = {}
61
+
62
+ for (const transform of request.pipelines.response) transform(message)
63
+
64
+ const headers = (message as { headers?: Headers }).headers
65
+
66
+ assert.equal(headers?.get('access-control-allow-origin'), 'https://hello.world')
67
+ assert.equal(headers?.get('access-control-allow-credentials'), 'true')
68
+ assert.equal(headers?.get('vary'), 'origin')
69
+ })
70
+
71
+ it('should pass a request carrying no origin through', () => {
72
+ assert.equal(cors.intercept(input('OPTIONS', {})), null)
73
+ assert.equal(cors.intercept(input('GET', {})), null)
74
+ })
75
+ })
@@ -16,7 +16,7 @@ export class CORS implements Interceptor {
16
16
  private requestHeaders = new Set<string>(REQUEST_HEADERS)
17
17
 
18
18
  private readonly headers = new Headers({
19
- 'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, LOCK, UNLOCK',
19
+ 'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, LOCK, UNLOCK, OPTIONS',
20
20
  'access-control-allow-credentials': 'true',
21
21
  'access-control-allow-headers': this.allowedHeaders(),
22
22
  'access-control-max-age': '3600',
@@ -26,8 +26,15 @@ export class CORS implements Interceptor {
26
26
 
27
27
  public intercept(input: Input): Output {
28
28
  const origin = input.request.headers.origin
29
-
30
- if (origin !== undefined && input.request.method === 'OPTIONS')
29
+ const requested = input.request.headers['access-control-request-method']
30
+
31
+ /*
32
+ * A preflight is `OPTIONS` carrying `Access-Control-Request-Method`, and only that. A
33
+ * browser puts `Origin` on every request whose method is not `GET` or `HEAD` — its own
34
+ * `OPTIONS` included, same-origin or not — so answering that one here would put
35
+ * introspection out of reach of any page.
36
+ */
37
+ if (origin !== undefined && requested !== undefined && input.request.method === 'OPTIONS')
31
38
  return this.preflightResponse(origin)
32
39
 
33
40
  input.pipelines.response.push((output) => {