@toa.io/extensions.exposition 1.0.0-alpha.286 → 1.0.0-alpha.288

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 (219) hide show
  1. package/CHANGELOG.md +16 -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.tokens/tsconfig.tsbuildinfo +1 -1
  15. package/documentation/access.md +22 -4
  16. package/documentation/discovery.md +81 -0
  17. package/documentation/help.md +109 -0
  18. package/documentation/introspection.md +34 -9
  19. package/documentation/mcp.md +13 -16
  20. package/features/cors.feature +31 -1
  21. package/features/dev.feature +2 -0
  22. package/features/discovery.feature +211 -0
  23. package/features/discovery.ui.feature +197 -0
  24. package/features/help.feature +466 -0
  25. package/features/introspection.feature +94 -8
  26. package/features/map.feature +1 -0
  27. package/features/mcp.feature +74 -12
  28. package/features/methods.feature +2 -1
  29. package/features/oauth.grants.feature +3 -1
  30. package/features/octets.download.feature +1 -0
  31. package/features/octets.meta.feature +1 -0
  32. package/features/site/_app/immutable/asset.js +1 -0
  33. package/features/site/favicon.ico +0 -0
  34. package/features/site/index.html +10 -0
  35. package/features/steps/Parameters.ts +12 -0
  36. package/package.json +6 -4
  37. package/readme.md +3 -0
  38. package/source/Directive.ts +4 -0
  39. package/source/Discovery/Explorer.ts +34 -0
  40. package/source/Discovery/Site.test.ts +147 -0
  41. package/source/Discovery/Site.ts +202 -0
  42. package/source/Discovery/index.ts +3 -0
  43. package/source/Discovery/tree.test.ts +175 -0
  44. package/source/Discovery/tree.ts +77 -0
  45. package/source/Discovery/trunk.ts +23 -0
  46. package/source/Endpoint.ts +4 -0
  47. package/source/Gateway.ts +34 -4
  48. package/source/HTTP/Context.ts +7 -0
  49. package/source/Introspection.ts +33 -10
  50. package/source/MCP/schema.ts +16 -5
  51. package/source/MCP/tools.ts +14 -32
  52. package/source/Mapping.ts +5 -0
  53. package/source/Query.ts +17 -2
  54. package/source/RTD/Directives.ts +10 -0
  55. package/source/RTD/Endpoint.ts +8 -1
  56. package/source/RTD/Node.ts +20 -6
  57. package/source/RTD/Tree.ts +19 -9
  58. package/source/RTD/factory.ts +37 -1
  59. package/source/RTD/segment.ts +21 -0
  60. package/source/const.ts +8 -0
  61. package/source/directives/auth/Anonymous.test.ts +31 -5
  62. package/source/directives/auth/Anonymous.ts +5 -3
  63. package/source/directives/auth/Anyone.ts +6 -0
  64. package/source/directives/auth/Assert.ts +5 -0
  65. package/source/directives/auth/Delegate.ts +5 -2
  66. package/source/directives/auth/Federation.ts +11 -0
  67. package/source/directives/auth/Id.ts +15 -0
  68. package/source/directives/auth/Input.ts +5 -0
  69. package/source/directives/auth/Role.ts +17 -0
  70. package/source/directives/cors/CORS.test.ts +75 -0
  71. package/source/directives/cors/CORS.ts +10 -3
  72. package/source/directives/help/Family.ts +118 -0
  73. package/source/directives/help/Help.test.ts +227 -0
  74. package/source/directives/help/Help.ts +26 -0
  75. package/source/directives/help/Parameters.ts +47 -0
  76. package/source/directives/help/described.ts +57 -0
  77. package/source/directives/help/index.ts +45 -0
  78. package/source/directives/index.ts +8 -1
  79. package/source/directives/map/Headers.ts +6 -6
  80. package/source/directives/mcp/MCP.ts +12 -21
  81. package/source/directives/mcp/Tool.test.ts +22 -68
  82. package/source/directives/mcp/Tool.ts +14 -41
  83. package/transpiled/Directive.d.ts +1 -0
  84. package/transpiled/Directive.js +3 -0
  85. package/transpiled/Directive.js.map +1 -1
  86. package/transpiled/Discovery/Explorer.d.ts +11 -0
  87. package/transpiled/Discovery/Explorer.js +29 -0
  88. package/transpiled/Discovery/Explorer.js.map +1 -0
  89. package/transpiled/Discovery/Site.d.ts +43 -0
  90. package/transpiled/Discovery/Site.js +171 -0
  91. package/transpiled/Discovery/Site.js.map +1 -0
  92. package/transpiled/Discovery/index.d.ts +3 -0
  93. package/transpiled/Discovery/index.js +4 -0
  94. package/transpiled/Discovery/index.js.map +1 -0
  95. package/transpiled/Discovery/tree.d.ts +26 -0
  96. package/transpiled/Discovery/tree.js +50 -0
  97. package/transpiled/Discovery/tree.js.map +1 -0
  98. package/transpiled/Discovery/trunk.d.ts +10 -0
  99. package/transpiled/Discovery/trunk.js +21 -0
  100. package/transpiled/Discovery/trunk.js.map +1 -0
  101. package/transpiled/Endpoint.d.ts +2 -1
  102. package/transpiled/Endpoint.js +3 -0
  103. package/transpiled/Endpoint.js.map +1 -1
  104. package/transpiled/Gateway.d.ts +4 -0
  105. package/transpiled/Gateway.js +29 -4
  106. package/transpiled/Gateway.js.map +1 -1
  107. package/transpiled/HTTP/Context.d.ts +6 -0
  108. package/transpiled/HTTP/Context.js +6 -0
  109. package/transpiled/HTTP/Context.js.map +1 -1
  110. package/transpiled/Introspection.d.ts +18 -7
  111. package/transpiled/Introspection.js +16 -1
  112. package/transpiled/Introspection.js.map +1 -1
  113. package/transpiled/MCP/schema.d.ts +2 -2
  114. package/transpiled/MCP/schema.js +11 -5
  115. package/transpiled/MCP/schema.js.map +1 -1
  116. package/transpiled/MCP/tools.d.ts +1 -1
  117. package/transpiled/MCP/tools.js +12 -28
  118. package/transpiled/MCP/tools.js.map +1 -1
  119. package/transpiled/Mapping.d.ts +2 -0
  120. package/transpiled/Mapping.js +4 -0
  121. package/transpiled/Mapping.js.map +1 -1
  122. package/transpiled/Query.d.ts +12 -0
  123. package/transpiled/Query.js +16 -2
  124. package/transpiled/Query.js.map +1 -1
  125. package/transpiled/RTD/Directives.d.ts +8 -0
  126. package/transpiled/RTD/Endpoint.d.ts +7 -1
  127. package/transpiled/RTD/Node.d.ts +11 -2
  128. package/transpiled/RTD/Node.js +12 -2
  129. package/transpiled/RTD/Node.js.map +1 -1
  130. package/transpiled/RTD/Tree.js +15 -8
  131. package/transpiled/RTD/Tree.js.map +1 -1
  132. package/transpiled/RTD/factory.js +27 -1
  133. package/transpiled/RTD/factory.js.map +1 -1
  134. package/transpiled/RTD/segment.d.ts +7 -0
  135. package/transpiled/RTD/segment.js +17 -0
  136. package/transpiled/RTD/segment.js.map +1 -1
  137. package/transpiled/const.d.ts +7 -0
  138. package/transpiled/const.js +7 -0
  139. package/transpiled/const.js.map +1 -1
  140. package/transpiled/directives/auth/Anonymous.d.ts +4 -2
  141. package/transpiled/directives/auth/Anonymous.js +5 -3
  142. package/transpiled/directives/auth/Anonymous.js.map +1 -1
  143. package/transpiled/directives/auth/Anyone.d.ts +3 -0
  144. package/transpiled/directives/auth/Anyone.js +4 -0
  145. package/transpiled/directives/auth/Anyone.js.map +1 -1
  146. package/transpiled/directives/auth/Assert.d.ts +2 -0
  147. package/transpiled/directives/auth/Assert.js +4 -0
  148. package/transpiled/directives/auth/Assert.js.map +1 -1
  149. package/transpiled/directives/auth/Delegate.d.ts +4 -1
  150. package/transpiled/directives/auth/Delegate.js +5 -2
  151. package/transpiled/directives/auth/Delegate.js.map +1 -1
  152. package/transpiled/directives/auth/Federation.d.ts +5 -0
  153. package/transpiled/directives/auth/Federation.js +8 -0
  154. package/transpiled/directives/auth/Federation.js.map +1 -1
  155. package/transpiled/directives/auth/Id.d.ts +9 -0
  156. package/transpiled/directives/auth/Id.js +12 -0
  157. package/transpiled/directives/auth/Id.js.map +1 -1
  158. package/transpiled/directives/auth/Input.d.ts +2 -0
  159. package/transpiled/directives/auth/Input.js +4 -0
  160. package/transpiled/directives/auth/Input.js.map +1 -1
  161. package/transpiled/directives/auth/Role.d.ts +7 -0
  162. package/transpiled/directives/auth/Role.js +11 -0
  163. package/transpiled/directives/auth/Role.js.map +1 -1
  164. package/transpiled/directives/cors/CORS.js +9 -2
  165. package/transpiled/directives/cors/CORS.js.map +1 -1
  166. package/transpiled/directives/help/Family.d.ts +28 -0
  167. package/transpiled/directives/help/Family.js +79 -0
  168. package/transpiled/directives/help/Family.js.map +1 -0
  169. package/transpiled/directives/help/Help.d.ts +16 -0
  170. package/transpiled/directives/help/Help.js +22 -0
  171. package/transpiled/directives/help/Help.js.map +1 -0
  172. package/transpiled/directives/help/Parameters.d.ts +18 -0
  173. package/transpiled/directives/help/Parameters.js +34 -0
  174. package/transpiled/directives/help/Parameters.js.map +1 -0
  175. package/transpiled/directives/help/described.d.ts +16 -0
  176. package/transpiled/directives/help/described.js +23 -0
  177. package/transpiled/directives/help/described.js.map +1 -0
  178. package/transpiled/directives/help/index.d.ts +22 -0
  179. package/transpiled/directives/help/index.js +34 -0
  180. package/transpiled/directives/help/index.js.map +1 -0
  181. package/transpiled/directives/index.d.ts +4 -0
  182. package/transpiled/directives/index.js +8 -1
  183. package/transpiled/directives/index.js.map +1 -1
  184. package/transpiled/directives/map/Headers.d.ts +5 -0
  185. package/transpiled/directives/map/Headers.js +7 -5
  186. package/transpiled/directives/map/Headers.js.map +1 -1
  187. package/transpiled/directives/mcp/MCP.d.ts +8 -5
  188. package/transpiled/directives/mcp/MCP.js +9 -13
  189. package/transpiled/directives/mcp/MCP.js.map +1 -1
  190. package/transpiled/directives/mcp/Tool.d.ts +6 -12
  191. package/transpiled/directives/mcp/Tool.js +10 -23
  192. package/transpiled/directives/mcp/Tool.js.map +1 -1
  193. package/tsconfig.tsbuildinfo +1 -1
  194. package/ui/dist/_app/env.js +1 -0
  195. package/ui/dist/_app/immutable/assets/0.BcIGtTfw.css +2 -0
  196. package/ui/dist/_app/immutable/assets/2.BC0-Jhqu.css +1 -0
  197. package/ui/dist/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
  198. package/ui/dist/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
  199. package/ui/dist/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
  200. package/ui/dist/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
  201. package/ui/dist/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
  202. package/ui/dist/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
  203. package/ui/dist/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
  204. package/ui/dist/_app/immutable/chunks/BgTrzN6u.js +1 -0
  205. package/ui/dist/_app/immutable/chunks/Bjy-W4x2.js +81 -0
  206. package/ui/dist/_app/immutable/chunks/CWJaG9mK.js +6 -0
  207. package/ui/dist/_app/immutable/chunks/DjIYL2zC.js +3 -0
  208. package/ui/dist/_app/immutable/chunks/xihTtKlq.js +1 -0
  209. package/ui/dist/_app/immutable/chunks/yDQhitF9.js +1 -0
  210. package/ui/dist/_app/immutable/entry/app.WZSMBcGf.js +2 -0
  211. package/ui/dist/_app/immutable/entry/start._LkoC6Lz.js +1 -0
  212. package/ui/dist/_app/immutable/nodes/0.Dl2DqINo.js +1 -0
  213. package/ui/dist/_app/immutable/nodes/1.CSSnGhVD.js +1 -0
  214. package/ui/dist/_app/immutable/nodes/2.DmolQyNG.js +14 -0
  215. package/ui/dist/_app/version.json +1 -0
  216. package/ui/dist/apple-touch-icon.png +0 -0
  217. package/ui/dist/favicon-96x96.png +0 -0
  218. package/ui/dist/favicon.ico +0 -0
  219. package/ui/dist/index.html +66 -0
@@ -62,7 +62,12 @@ Feature: Introspection
62
62
  required:
63
63
  - title
64
64
  - volume
65
- output: {}
65
+ output:
66
+ type: object
67
+ properties:
68
+ title:
69
+ type: string
70
+ maxLength: 64
66
71
  errors:
67
72
  - NO_WAY
68
73
  - WONT_CREATE
@@ -79,7 +84,8 @@ Feature: Introspection
79
84
  /:
80
85
  io:output: true
81
86
  GET:
82
- mcp:tool: Every pot there is, newest first.
87
+ help:method:
88
+ description: Every pot there is, newest first.
83
89
  endpoint: enumerate
84
90
  POST: create
85
91
  """
@@ -214,6 +220,85 @@ Feature: Introspection
214
220
  POST:
215
221
  """
216
222
 
223
+ Scenario: A credential does not hide what is anonymous
224
+ A credential refuses an `anonymous` route because it would make the reply uncacheable,
225
+ and a description is not that reply — it is what the resource is, and it varies by who
226
+ asked in any case. Without this an identity would be told less than someone presenting
227
+ nothing at all.
228
+
229
+ Given the `pots` is running with the following manifest:
230
+ """yaml
231
+ exposition:
232
+ /:
233
+ isolated: true
234
+ anonymous: true
235
+ io:output: [id]
236
+ GET: enumerate
237
+ """
238
+ When the following request is received:
239
+ """
240
+ OPTIONS /pots/ HTTP/1.1
241
+ host: nex.toa.io
242
+ authorization: Basic ZGV2ZWxvcGVyOnNlY3JldA==
243
+ accept: application/yaml
244
+ """
245
+ Then the following reply is sent:
246
+ """
247
+ 200 OK
248
+ Allow: GET
249
+ """
250
+
251
+ Scenario: A method that takes an identity is not described to nobody
252
+ Which identity it is about cannot be told from a description, which has no route
253
+ variable to read. That there must be one can, and a caller with none is refused
254
+ whatever the value would have been — so they are not shown it.
255
+
256
+ Given the `identity.basic` database contains:
257
+ # developer:secret
258
+ | _id | authority | username | password |
259
+ | efe3a65ebbee47ed95a73edd911ea328 | nex | developer | $2b$10$ZRSKkgZoGnrcTNA5w5eCcu3pxDzdTduhteVYXcp56AaNcilNkwJ.O |
260
+ And the `identity.bans` database is empty
261
+ And the `pots` is running with the following manifest:
262
+ """yaml
263
+ exposition:
264
+ /:id:
265
+ isolated: true
266
+ io:output: [id]
267
+ GET:
268
+ anonymous: true
269
+ endpoint: observe
270
+ PATCH:
271
+ auth:id: id
272
+ endpoint: assign
273
+ """
274
+ When the following request is received:
275
+ """
276
+ OPTIONS /pots/:id/ HTTP/1.1
277
+ host: nex.toa.io
278
+ accept: application/yaml
279
+ """
280
+ Then the following reply is sent:
281
+ """
282
+ 200 OK
283
+ Allow: GET
284
+ """
285
+ And the reply does not contain:
286
+ """
287
+ PATCH:
288
+ """
289
+ When the following request is received:
290
+ """
291
+ OPTIONS /pots/:id/ HTTP/1.1
292
+ host: nex.toa.io
293
+ authorization: Basic ZGV2ZWxvcGVyOnNlY3JldA==
294
+ accept: application/yaml
295
+ """
296
+ Then the following reply is sent:
297
+ """
298
+ 200 OK
299
+ Allow: GET, PATCH
300
+ """
301
+
217
302
  Scenario: A resource the caller cannot reach at all
218
303
  Given the `pots` is running with the following manifest:
219
304
  """yaml
@@ -274,8 +359,9 @@ Feature: Introspection
274
359
  """
275
360
 
276
361
  Scenario: A property a header carries
277
- `map:headers` fills the property from the request, so it is not the body's to send —
278
- and the caller is told where it does go.
362
+ `map:headers` fills the property from the request, so it is not the body's to send.
363
+ Which header it reads is not answered: what an application takes off a request is its
364
+ own, and a caller has nowhere to put one.
279
365
 
280
366
  Given the `echo` is running with the following manifest:
281
367
  """yaml
@@ -299,16 +385,16 @@ Feature: Introspection
299
385
  Allow: PATCH
300
386
 
301
387
  PATCH:
302
- headers:
303
- a:
304
- type: string
305
- header: x-first
306
388
  input:
307
389
  type: object
308
390
  properties:
309
391
  b:
310
392
  type: string
311
393
  """
394
+ And the reply does not contain:
395
+ """
396
+ x-first
397
+ """
312
398
 
313
399
  Scenario: A property a segment carries under another name
314
400
  `map:segments` names the property differently from the segment it reads, so the route
@@ -78,6 +78,7 @@ Feature: HTTP context mapping
78
78
  When the following request is received:
79
79
  """
80
80
  OPTIONS / HTTP/1.1
81
+ access-control-request-method: GET
81
82
  host: nex.toa.io
82
83
  origin: https://example.com
83
84
  """
@@ -1,11 +1,12 @@
1
1
  @security
2
2
  Feature: Model Context Protocol
3
3
 
4
- A tool is a procedure a method is published as. A method says so with `mcp:tool`, whose
5
- value is what the tool is, and a default denies: a tree holds everything an application
6
- serves and most of it is machinery a model has no business reading. What a tool takes and
7
- answers is what the method says of itself, and what a caller may do with it is what `auth`
8
- says.
4
+ A tool is a procedure a method is published as. A method says so with `mcp:tool`, and a
5
+ default denies: a tree holds everything an application serves and most of it is machinery
6
+ a model has no business reading. What the tool is called and what it is for is
7
+ `help:method`, which is what describes the method everywhere else as well. What a tool
8
+ takes and answers is what the method says of itself, and what a caller may do with it is
9
+ what `auth` says.
9
10
 
10
11
  What a tool is, is stated on the route and nowhere else. The operation states what it is
11
12
  too, and that is not this: one operation mounted on two routes is two tools. A `title`
@@ -30,20 +31,27 @@ Feature: Model Context Protocol
30
31
  /:
31
32
  io:output: [id, title, volume]
32
33
  GET:
33
- mcp:tool: All the pots, newest first.
34
+ mcp:tool: true
35
+ help:method:
36
+ description: All the pots, newest first.
34
37
  endpoint: enumerate
35
38
  POST:
36
- mcp:tool: Start a new pot brewing.
39
+ mcp:tool: true
40
+ help:method:
41
+ description: Start a new pot brewing.
37
42
  endpoint: create
38
43
  /:id:
39
44
  GET:
40
- mcp:tool: One pot, by its id.
45
+ mcp:tool: true
46
+ help:method:
47
+ description: One pot, by its id.
41
48
  endpoint: observe
42
49
  /large:
43
50
  GET:
44
51
  query:
45
52
  criteria: volume>=100
46
- mcp:tool:
53
+ mcp:tool: true
54
+ help:method:
47
55
  title: Large pots
48
56
  description: The pots that hold a hundred or more, newest first.
49
57
  endpoint: enumerate
@@ -53,7 +61,9 @@ Feature: Model Context Protocol
53
61
  isolated: true
54
62
  GET:
55
63
  auth:role: admin
56
- mcp:tool: Every pot there is, for whoever may see them all.
64
+ mcp:tool: true
65
+ help:method:
66
+ description: Every pot there is, for whoever may see them all.
57
67
  endpoint: enumerate
58
68
  """
59
69
  And the `pots` database contains:
@@ -256,6 +266,54 @@ Feature: Model Context Protocol
256
266
  code: -32601
257
267
  """
258
268
 
269
+ Scenario: A tool that takes an identity is not listed to nobody
270
+ `tools/list` answers what this caller may reach, and it is the description that decides
271
+ — the same one `OPTIONS` answers. A model is not shown a tool it would be refused.
272
+
273
+ Given the annotation:
274
+ """yaml
275
+ mcp:
276
+ name: Pots
277
+ anonymous: true
278
+ /:
279
+ anonymous: true
280
+ /pots/:id:
281
+ isolated: true
282
+ GET:
283
+ anonymous: true
284
+ mcp:tool: true
285
+ help:method: One pot, by its id.
286
+ dev:stub: []
287
+ PATCH:
288
+ auth:id: id
289
+ mcp:tool: true
290
+ help:method: Change your own pot.
291
+ dev:stub: []
292
+ """
293
+ When the following request is received:
294
+ """
295
+ POST /.mcp HTTP/1.1
296
+ host: nex.toa.io
297
+ accept: application/yaml
298
+ content-type: application/json
299
+ mcp-protocol-version: 2026-07-28
300
+ mcp-method: tools/list
301
+
302
+ {"jsonrpc": "2.0", "id": 3, "method": "tools/list",
303
+ "params": {"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
304
+ "io.modelcontextprotocol/clientCapabilities": {}}}}
305
+ """
306
+ Then the following reply is sent:
307
+ """
308
+ 200 OK
309
+
310
+ - name: pots._id.GET
311
+ """
312
+ And the reply does not contain:
313
+ """
314
+ pots._id.PATCH
315
+ """
316
+
259
317
  Scenario: A credential does not hide an anonymous tool
260
318
  A client here always presents one, and what refuses a credentialed request at an
261
319
  `anonymous` route is that the reply would not be cacheable — which a tool's is not.
@@ -563,7 +621,9 @@ Feature: Model Context Protocol
563
621
  /:
564
622
  io:output: true
565
623
  GET:
566
- mcp:tool: Refuses, with what it was given.
624
+ mcp:tool: true
625
+ help:method:
626
+ description: Refuses, with what it was given.
567
627
  endpoint: error
568
628
  """
569
629
  When the following request is received:
@@ -605,7 +665,9 @@ Feature: Model Context Protocol
605
665
  /:
606
666
  io:output: true
607
667
  GET:
608
- mcp:tool: Answers with what it was given.
668
+ mcp:tool: true
669
+ help:method:
670
+ description: Answers with what it was given.
609
671
  endpoint: echo
610
672
  """
611
673
  When the following request is received:
@@ -37,11 +37,12 @@ Feature: Supported methods
37
37
  When the following request is received:
38
38
  """
39
39
  OPTIONS / HTTP/1.1
40
+ access-control-request-method: GET
40
41
  host: nex.toa.io
41
42
  origin: https://hello.world
42
43
  """
43
44
  Then the following reply is sent:
44
45
  """
45
46
  204 No Content
46
- access-control-allow-methods: GET, POST, PUT, PATCH, DELETE, LOCK, UNLOCK
47
+ access-control-allow-methods: GET, POST, PUT, PATCH, DELETE, LOCK, UNLOCK, OPTIONS
47
48
  """
@@ -26,7 +26,9 @@ Feature: Authorization code flow
26
26
  /pots:
27
27
  anyone: true
28
28
  GET:
29
- mcp:tool: Every pot there is.
29
+ mcp:tool: true
30
+ help:method:
31
+ description: Every pot there is.
30
32
  dev:stub: Kettles and teapots.
31
33
  """
32
34
  When the following request is received:
@@ -183,6 +183,7 @@ Feature: Download external resources
183
183
  When the following request is received:
184
184
  """
185
185
  OPTIONS / HTTP/1.1
186
+ access-control-request-method: GET
186
187
  host: nex.toa.io
187
188
  origin: https://hello.world
188
189
  """
@@ -105,6 +105,7 @@ Feature: Octets metadata
105
105
  When the following request is received:
106
106
  """
107
107
  OPTIONS / HTTP/1.1
108
+ access-control-request-method: GET
108
109
  host: nex.toa.io
109
110
  origin: https://example.com
110
111
  """
@@ -0,0 +1 @@
1
+ export const hashed = true
Binary file
@@ -0,0 +1,10 @@
1
+ <!doctype html>
2
+ <!-- stands in for what `ui` builds; the scenarios are about the server, not the page -->
3
+ <html lang="en">
4
+ <head>
5
+ <title>fixture</title>
6
+ </head>
7
+ <body>
8
+ the page
9
+ </body>
10
+ </html>
@@ -40,6 +40,18 @@ process.env.TOA_TELEMETRY_TRACES ??= JSON.stringify({
40
40
  }
41
41
  })
42
42
 
43
+ /*
44
+ * The page the gateway serves at `/.discovery/`, pointed at a fixture: the scenarios are
45
+ * about the server, not the page, and none of them should need a UI build. Set here rather
46
+ * than in a step because the gateway is not rebuilt between scenarios that share its
47
+ * signature, so one flipping this would silently read whatever the last one left.
48
+ */
49
+ process.env.__TESTING_EXPOSITION_DISCOVERY_ROOT = join(
50
+ import.meta.dirname,
51
+ '..',
52
+ 'site'
53
+ )
54
+
43
55
  const environment = process.env.CLOUDINARY_ENVIRONMENT
44
56
 
45
57
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/extensions.exposition",
3
- "version": "1.0.0-alpha.286",
3
+ "version": "1.0.0-alpha.288",
4
4
  "type": "module",
5
5
  "description": "Toa Exposition",
6
6
  "author": "temich <tema.gurtovoy@gmail.com>",
@@ -20,9 +20,9 @@
20
20
  "dependencies": {
21
21
  "@node-rs/bcrypt": "1.10.8",
22
22
  "@simplewebauthn/server": "14.0.0",
23
- "@toa.io/core": "1.0.0-alpha.286",
23
+ "@toa.io/core": "1.0.0-alpha.288",
24
24
  "@toa.io/generic": "1.0.0-alpha.286",
25
- "@toa.io/schemas": "1.0.0-alpha.286",
25
+ "@toa.io/schemas": "1.0.0-alpha.287",
26
26
  "content-type": "3.0.0",
27
27
  "jose": "6.2.10",
28
28
  "js-yaml": "5.4.1",
@@ -48,6 +48,8 @@
48
48
  "transpile:otp": "tsc -p ./components/identity.otp",
49
49
  "transpile:clients": "tsc -p ./components/identity.clients",
50
50
  "transpile:grants": "tsc -p ./components/identity.grants",
51
+ "build:ui": "npm --prefix ui ci && npm --prefix ui run build",
52
+ "prepack": "npm run build:ui",
51
53
  "features": "TSX_TSCONFIG_PATH=features/steps/tsconfig.json NODE_OPTIONS=--import=tsx cucumber-js",
52
54
  "features:h2c": "TOA_EXPOSITION_PROTOCOL=h2c TOA_AGENT_PROTOCOL=h2c TSX_TSCONFIG_PATH=features/steps/tsconfig.json NODE_OPTIONS=--import=tsx cucumber-js",
53
55
  "features:security": "TSX_TSCONFIG_PATH=features/steps/tsconfig.json NODE_OPTIONS=--import=tsx cucumber-js --tags @security",
@@ -58,5 +60,5 @@
58
60
  "@types/cors": "2.8.19",
59
61
  "@types/negotiator": "0.6.5"
60
62
  },
61
- "gitHead": "81f3d0688f17668b902be336d1749cc715edcdb6"
63
+ "gitHead": "d3d696f0a4e287c3c297ac81028d600ad7574112"
62
64
  }
package/readme.md CHANGED
@@ -211,6 +211,9 @@ See also [telemetry ready probe](../telemetry/readme.md#ready-probe) and
211
211
  - [Resource Tree Definition](documentation/tree.md)
212
212
  - [Identity authentication](documentation/identity.md)
213
213
  - [Access authorization](documentation/access.md)
214
+ - [Resource introspection](documentation/introspection.md)
215
+ - [Help](documentation/help.md)
216
+ - [Resource discovery](documentation/discovery.md)
214
217
  - [OAuth authorization server](documentation/oauth.md)
215
218
  - [Consent](documentation/consent.md)
216
219
  - [BLOBs](documentation/octets.md)
@@ -206,6 +206,10 @@ export class DirectivesFactory implements RTD.DirectiveFactory {
206
206
  return directives
207
207
  }
208
208
 
209
+ public inheritable(directive: RTD.syntax.Directive): boolean {
210
+ return this.families[directive.family]?.inherited !== false
211
+ }
212
+
209
213
  public dispose(): void {
210
214
  for (const directives of this.instances) directives.dispose()
211
215
  }
@@ -0,0 +1,34 @@
1
+ import * as http from '../HTTP/index.js'
2
+ import { BRANCH_TTL } from '../const.js'
3
+ import { describe } from './tree.js'
4
+ import type { Tree } from '../RTD/index.js'
5
+
6
+ /**
7
+ * What an application serves, for every route at once. Each entry is what `OPTIONS` on that
8
+ * path answers, so a key taken from here is a request that can be made.
9
+ */
10
+ export class Explorer {
11
+ private readonly tree: Tree
12
+
13
+ public constructor(tree: Tree) {
14
+ this.tree = tree
15
+ }
16
+
17
+ public async process(context: http.Context): Promise<http.OutgoingMessage> {
18
+ if (context.request.method !== 'OPTIONS')
19
+ throw new http.MethodNotAllowed(new Headers({ allow: ALLOW }))
20
+
21
+ return {
22
+ body: await describe(this.tree, context),
23
+ headers: new Headers({
24
+ allow: ALLOW,
25
+ // it stands until a branch expires, and it is filtered by who asked
26
+ 'cache-control': `private, max-age=${BRANCH_TTL / 1000}`,
27
+ vary: 'authorization'
28
+ })
29
+ }
30
+ }
31
+ }
32
+
33
+ /** The verbs of this path, which are not the verbs of the tree it answers with. */
34
+ const ALLOW = 'GET, HEAD, OPTIONS'
@@ -0,0 +1,147 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { Readable } from 'node:stream'
4
+ import * as path from 'node:path'
5
+ import * as fs from 'node:fs'
6
+ import * as os from 'node:os'
7
+
8
+ import { Site } from './Site.js'
9
+ import * as http from '../HTTP/index.js'
10
+ import type { Input, Output } from '../io.js'
11
+
12
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'discovery-'))
13
+
14
+ fs.mkdirSync(path.join(root, '_app', 'immutable'), { recursive: true })
15
+ fs.writeFileSync(path.join(root, 'index.html'), 'the page\n')
16
+ fs.writeFileSync(path.join(root, 'favicon.ico'), 'icon')
17
+ fs.writeFileSync(path.join(root, '_app', 'immutable', 'asset.js'), 'export {}\n')
18
+
19
+ const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'discovery-empty-'))
20
+
21
+ const site = new Site(root)
22
+
23
+ const input = (url: string, method = 'GET'): Input =>
24
+ ({
25
+ request: { method, headers: {} },
26
+ url: new URL(url, 'http://nex.toa.io')
27
+ }) as unknown as Input
28
+
29
+ const header = (output: Output, name: string): string | null =>
30
+ output?.headers?.get(name) ?? null
31
+
32
+ describe('discovery site', () => {
33
+ it('should leave a path outside its prefix alone', () => {
34
+ assert.equal(site.intercept(input('/')), null)
35
+ assert.equal(site.intercept(input('/pots/')), null)
36
+ })
37
+
38
+ it('should not claim a path that merely starts like its own', () => {
39
+ assert.equal(site.intercept(input('/.discoveryable/')), null)
40
+ })
41
+
42
+ it('should send the bare path to the directory it is', () => {
43
+ const output = site.intercept(input('/.discovery'))
44
+
45
+ assert.equal(output?.status, 302)
46
+ assert.equal(header(output, 'location'), '/.discovery/')
47
+ })
48
+
49
+ it('should keep the query when it redirects', () => {
50
+ const output = site.intercept(input('/.discovery?at=/pots'))
51
+
52
+ assert.equal(header(output, 'location'), '/.discovery/?at=/pots')
53
+ })
54
+
55
+ it('should serve the page', () => {
56
+ const output = site.intercept(input('/.discovery/'))
57
+
58
+ assert.equal(output?.status, 200)
59
+ assert.equal(header(output, 'content-type'), 'text/html; charset=utf-8')
60
+ assert.equal(header(output, 'cache-control'), 'no-cache')
61
+ })
62
+
63
+ it('should always carry a content-type and a stream', () => {
64
+ // a stream without one is framed as `multipart/*`, and the page arrives as an envelope
65
+ for (const url of ['/.discovery/', '/.discovery/_app/immutable/asset.js']) {
66
+ const output = site.intercept(input(url))
67
+
68
+ assert.ok(output?.headers?.has('content-type'), url)
69
+ assert.ok(output?.body instanceof Readable, url)
70
+ }
71
+ })
72
+
73
+ it('should hold a hashed asset forever and an icon for a day', () => {
74
+ assert.equal(
75
+ header(site.intercept(input('/.discovery/_app/immutable/asset.js')), 'cache-control'),
76
+ 'public, max-age=31536000, immutable'
77
+ )
78
+ assert.equal(
79
+ header(site.intercept(input('/.discovery/favicon.ico')), 'cache-control'),
80
+ 'public, max-age=86400'
81
+ )
82
+ })
83
+
84
+ it('should fall back to the page for anything that could be a route', () => {
85
+ const output = site.intercept(input('/.discovery/pots/:id'))
86
+
87
+ assert.equal(header(output, 'content-type'), 'text/html; charset=utf-8')
88
+ })
89
+
90
+ it('should refuse an asset that is not there', () => {
91
+ // a missing asset is missing, however much a route can look like one
92
+ assert.throws(
93
+ () => site.intercept(input('/.discovery/_app/immutable/gone.js')),
94
+ http.NotFound
95
+ )
96
+ })
97
+
98
+ it('should refuse a path that climbs out of the root', () => {
99
+ /*
100
+ * An encoded separator is what reaches here: `..` and `%2e%2e` are dot segments and the
101
+ * URL parser has already collapsed them, but `%2f` keeps a segment whole through it and
102
+ * decodes to one afterwards.
103
+ */
104
+ for (const url of [
105
+ '/.discovery/%2f%2e%2e%2f%2e%2e%2fpackage.json',
106
+ '/.discovery/a%2f..%2f..%2fpackage.json',
107
+ '/.discovery/%2e%2e%2fpackage.json'
108
+ ])
109
+ assert.throws(() => site.intercept(input(url)), http.NotFound, url)
110
+ })
111
+
112
+ it('should never see a dot segment, which the URL parser collapses first', () => {
113
+ // `/.discovery/%2e%2e/x` is `/x`, which is not this prefix and not ours to answer
114
+ assert.equal(site.intercept(input('/.discovery/%2e%2e/%2e%2e/package.json')), null)
115
+ assert.equal(site.intercept(input('/.discovery/../../package.json')), null)
116
+ })
117
+
118
+ it('should report the length a GET would have returned, and open no stream', () => {
119
+ const output = site.intercept(input('/.discovery/', 'HEAD'))
120
+
121
+ assert.equal(output?.status, 200)
122
+ assert.equal(header(output, 'content-length'), '9')
123
+ assert.equal(output?.body, undefined)
124
+ })
125
+
126
+ it('should leave OPTIONS on the prefix to the tree', () => {
127
+ // it needs the identity, which is resolved after this stage
128
+ assert.equal(site.intercept(input('/.discovery', 'OPTIONS')), null)
129
+ assert.equal(site.intercept(input('/.discovery/', 'OPTIONS')), null)
130
+ })
131
+
132
+ it('should refuse every other method', () => {
133
+ assert.throws(() => site.intercept(input('/.discovery/', 'POST')), http.MethodNotAllowed)
134
+ assert.throws(
135
+ () => site.intercept(input('/.discovery/favicon.ico', 'OPTIONS')),
136
+ http.MethodNotAllowed
137
+ )
138
+ })
139
+
140
+ it('should say so where the page is not built', () => {
141
+ const output = new Site(empty).intercept(input('/.discovery/'))
142
+
143
+ assert.equal(output?.status, 503)
144
+ assert.equal(header(output, 'content-type'), 'text/plain; charset=utf-8')
145
+ assert.ok(output?.body instanceof Readable)
146
+ })
147
+ })