@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
@@ -0,0 +1,202 @@
1
+ import * as fs from 'node:fs'
2
+ import * as path from 'node:path'
3
+ import { Readable } from 'node:stream'
4
+ import { console } from 'openspan'
5
+ import * as http from '../HTTP/index.js'
6
+ import { DISCOVERY } from '../const.js'
7
+ import type { Input, Output } from '../io.js'
8
+ import type { Interceptor } from '../Interception.js'
9
+
10
+ /**
11
+ * The page that reads the tree: the directory `ui` builds, and nothing else.
12
+ *
13
+ * An interceptor rather than an endpoint, for the reason the OAuth documents are one — it
14
+ * runs before a credential is resolved. A page is public, and one served after `auth` would
15
+ * refuse the client holding a stale token, who is the client most likely to have opened it.
16
+ * It is also what keeps `depart` off these replies: a re-issued credential sets `no-store`,
17
+ * which would take the build hash off every asset that carries one.
18
+ *
19
+ * It claims the whole prefix, so that nothing under it reaches the router — a crawler
20
+ * asking for assets that are not there would otherwise have every one of them counted as a
21
+ * route that has gone missing, and ping discovery about it.
22
+ *
23
+ * `OPTIONS` on the prefix itself is the exception it lets through: the tree is what answers
24
+ * there, and that needs the identity this stage does not have yet.
25
+ */
26
+ export class Site implements Interceptor {
27
+ public readonly name = 'discovery'
28
+
29
+ /** What a test points at a fixture; the build otherwise. */
30
+ private readonly override: string | undefined
31
+ private root: string
32
+
33
+ public constructor(root?: string) {
34
+ this.override = root
35
+ this.root = root ?? site()
36
+ }
37
+
38
+ /**
39
+ * Resolved here rather than at construction because this is a module singleton, built
40
+ * when the module loads and before anything has said where the page is. Said once, too,
41
+ * because a page that is not built is otherwise a `503` per request and nothing else.
42
+ */
43
+ public mount(): void {
44
+ this.root = this.override ?? site()
45
+
46
+ if (!isFile(path.join(this.root, 'index.html')))
47
+ console.warn('Discovery UI is not built', { root: this.root })
48
+ }
49
+
50
+ public intercept(input: Input): Output {
51
+ const { pathname } = input.url
52
+
53
+ if (pathname !== DISCOVERY && !pathname.startsWith(DISCOVERY + '/')) return null
54
+
55
+ // the prefix itself, as opposed to something under it
56
+ const bare = pathname === DISCOVERY || pathname === DISCOVERY + '/'
57
+ const method = input.request.method
58
+
59
+ if (method !== 'GET' && method !== 'HEAD') {
60
+ // the tree answers here, and it needs the identity this stage does not have yet
61
+ if (bare && method === 'OPTIONS') return null
62
+
63
+ throw new http.MethodNotAllowed(
64
+ new Headers({ allow: bare ? 'GET, HEAD, OPTIONS' : 'GET, HEAD' })
65
+ )
66
+ }
67
+
68
+ // the page is a directory, and every asset it names is relative to it
69
+ if (pathname === DISCOVERY)
70
+ return {
71
+ status: 302,
72
+ headers: new Headers({ location: DISCOVERY + '/' + input.url.search })
73
+ }
74
+
75
+ const file = this.resolve(pathname)
76
+
77
+ if (file === null) throw new http.NotFound()
78
+
79
+ return this.send(file, method)
80
+ }
81
+
82
+ /**
83
+ * The file a request lands on, or `null` when nothing does. A path that exists is served
84
+ * as it is; anything else that could be a route falls back to the page, because the
85
+ * client router — not this server — knows what routes there are.
86
+ */
87
+ private resolve(pathname: string): string | null {
88
+ /*
89
+ * Decoded after the prefix is off, so that an encoded separator cannot smuggle a
90
+ * segment past the test that put us here. A dot segment never arrives — `..` and
91
+ * `%2e%2e` alike are collapsed while the URL is parsed — but `%2f` survives that and
92
+ * decodes to a separator here, which is what the guard below is for.
93
+ */
94
+ const relative = decode(pathname.slice(DISCOVERY.length))
95
+
96
+ if (relative === null || relative.includes('\0')) return null
97
+
98
+ const file = path.join(this.root, relative)
99
+
100
+ if (file !== this.root && !file.startsWith(this.root + path.sep)) return null
101
+
102
+ if (isFile(file)) return file
103
+
104
+ /*
105
+ * A missing asset is missing, but a route can look like one. What this server would
106
+ * have served is what it knows how to serve, so anything else is a route and falls
107
+ * back to the page — as does anything ending in a slash, which is no name for a file.
108
+ */
109
+ const asset = !relative.endsWith('/') && path.extname(relative) in TYPES
110
+
111
+ return asset ? null : path.join(this.root, 'index.html')
112
+ }
113
+
114
+ /**
115
+ * Always with a `content-type` of its own: a stream without one is framed as
116
+ * `multipart/*` and the page arrives as an envelope of JSON-encoded buffers.
117
+ */
118
+ private send(file: string, method: string): Output {
119
+ const stats = fs.statSync(file, { throwIfNoEntry: false })
120
+
121
+ if (stats === undefined)
122
+ return {
123
+ status: 503,
124
+ headers: new Headers({ 'content-type': 'text/plain; charset=utf-8' }),
125
+ body: Readable.from([Buffer.from(NOT_BUILT)])
126
+ }
127
+
128
+ const headers = new Headers({
129
+ 'content-type': TYPES[path.extname(file)] ?? 'application/octet-stream',
130
+ 'content-length': String(stats.size),
131
+ 'cache-control': caching(path.relative(this.root, file))
132
+ })
133
+
134
+ // a HEAD reply carries no body but reports the length a GET would have returned; the
135
+ // HTTP/2 layer does not drop one written to it, so none is opened
136
+ if (method === 'HEAD') return { status: 200, headers }
137
+
138
+ return { status: 200, headers, body: fs.createReadStream(file) }
139
+ }
140
+ }
141
+
142
+ const NOT_BUILT = 'The discovery UI is not built. Run `npm run build:ui`.\n'
143
+
144
+ function decode(pathname: string): string | null {
145
+ try {
146
+ return decodeURIComponent(pathname)
147
+ } catch {
148
+ return null
149
+ }
150
+ }
151
+
152
+ function isFile(file: string): boolean {
153
+ return fs.statSync(file, { throwIfNoEntry: false })?.isFile() === true
154
+ }
155
+
156
+ /**
157
+ * Where `npm run build:ui` puts the page. Two levels up because this module is a directory
158
+ * deeper than `source` itself, and `transpiled` mirrors that — so it resolves the same from
159
+ * either. The variable is the features', which run against a fixture rather than a build.
160
+ */
161
+ function site(): string {
162
+ return (
163
+ process.env.__TESTING_EXPOSITION_DISCOVERY_ROOT ??
164
+ path.resolve(import.meta.dirname, '..', '..', 'ui', 'dist')
165
+ )
166
+ }
167
+
168
+ /** Assets under this prefix carry their build hash in the name. */
169
+ const IMMUTABLE = path.join('_app', 'immutable')
170
+ const FOREVER = 'public, max-age=31536000, immutable'
171
+
172
+ /** The icons: named without a hash, so they are re-read, but rarely. */
173
+ const ICONS = new Set(['favicon.ico', 'favicon-96x96.png', 'apple-touch-icon.png'])
174
+ const DAY = 'public, max-age=86400'
175
+
176
+ /**
177
+ * How long what is served may be held. The page is never cached — it names the assets, and
178
+ * their names carry the build. An icon is named for what it is rather than for its content,
179
+ * so it is asked about again, but not on every page load.
180
+ */
181
+ function caching(relative: string): string {
182
+ if (relative.startsWith(IMMUTABLE)) return FOREVER
183
+
184
+ return ICONS.has(relative) ? DAY : 'no-cache'
185
+ }
186
+
187
+ const TYPES: Record<string, string> = {
188
+ '.css': 'text/css; charset=utf-8',
189
+ '.html': 'text/html; charset=utf-8',
190
+ '.ico': 'image/x-icon',
191
+ '.jpg': 'image/jpeg',
192
+ '.js': 'text/javascript; charset=utf-8',
193
+ '.json': 'application/json; charset=utf-8',
194
+ '.map': 'application/json; charset=utf-8',
195
+ '.png': 'image/png',
196
+ '.svg': 'image/svg+xml',
197
+ '.txt': 'text/plain; charset=utf-8',
198
+ '.webmanifest': 'application/manifest+json',
199
+ '.webp': 'image/webp',
200
+ '.woff': 'font/woff',
201
+ '.woff2': 'font/woff2'
202
+ }
@@ -0,0 +1,3 @@
1
+ export { Explorer } from './Explorer.js'
2
+ export { Site } from './Site.js'
3
+ export { looking } from './trunk.js'
@@ -0,0 +1,175 @@
1
+ import { describe as suite, it, afterEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { describe } from './tree.js'
5
+ import { Tree } from '../RTD/Tree.js'
6
+ import type * as http from '../HTTP/index.js'
7
+ import type { Introspection } from '../Introspection.js'
8
+ import type { EndpointsFactory } from '../Endpoint.js'
9
+ import type { DirectiveFactory } from '../RTD/Directives.js'
10
+ import type * as syntax from '../RTD/syntax/index.js'
11
+
12
+ /** A method with no mapping has no endpoint, so what it says is its directives' alone. */
13
+ const endpoints = {} as unknown as EndpointsFactory
14
+
15
+ /** What a method marked with this describes as, standing for a directive that refuses. */
16
+ const REFUSE = 'refuse'
17
+
18
+ const directives = {
19
+ create: (stack: syntax.Directive[]) => ({
20
+ declared: () => undefined,
21
+ precall: async () => null,
22
+ settle: async () => undefined,
23
+ dispose: () => undefined,
24
+ explain: async (_: unknown, introspection: Introspection) => {
25
+ const mark = stack.find((directive) => directive.family === 'test')?.value
26
+
27
+ if (mark === REFUSE) return null
28
+
29
+ return mark === undefined ? introspection : { ...introspection, description: mark }
30
+ }
31
+ }),
32
+ preflight: async () => undefined,
33
+ depart: async () => undefined,
34
+ dispose: () => undefined
35
+ } as unknown as DirectiveFactory
36
+
37
+ const method = (verb: string, mark?: string): syntax.Method => ({
38
+ verb,
39
+ directives: mark === undefined ? [] : [{ family: 'test', name: 'mark', value: mark }]
40
+ })
41
+
42
+ const node = (
43
+ path: string,
44
+ methods: syntax.Method[] = [],
45
+ routes: syntax.Route[] = []
46
+ ): syntax.Route => ({ path, node: { routes, methods, directives: [] } })
47
+
48
+ const trunk = (routes: syntax.Route[], methods: syntax.Method[] = []): syntax.Node => ({
49
+ routes,
50
+ methods,
51
+ directives: []
52
+ })
53
+
54
+ const request = {} as unknown as http.Context
55
+
56
+ afterEach(() => {
57
+ delete process.env.__TESTING_EXPOSITION_BRANCH_TTL
58
+ })
59
+
60
+ suite('discovery tree', () => {
61
+ it('should key the trunk by the root', async () => {
62
+ const tree = new Tree(trunk([], [method('GET')]), endpoints, directives)
63
+
64
+ assert.deepEqual(Object.keys((await describe(tree, request)).routes), ['/'])
65
+ })
66
+
67
+ it('should key every route by the template it answers at', async () => {
68
+ const tree = new Tree(
69
+ trunk([
70
+ node('/pots', [method('GET'), method('POST')], [node('/:id', [method('GET')])])
71
+ ]),
72
+ endpoints,
73
+ directives
74
+ )
75
+
76
+ const { routes: resources } = await describe(tree, request)
77
+
78
+ assert.deepEqual(Object.keys(resources), ['/pots', '/pots/:id'])
79
+ assert.deepEqual(Object.keys(resources['/pots']), ['GET', 'POST'])
80
+ })
81
+
82
+ it('should include a route no name can spell', async () => {
83
+ // `/.rpc` and `/.mcp` leave these out; HTTP addresses a route by its path
84
+ const tree = new Tree(
85
+ trunk([
86
+ node('/v1.0', [method('GET')]),
87
+ node('/files', [], [node('/**', [method('GET')])]),
88
+ node('/any', [], [node('/*', [method('GET')])])
89
+ ]),
90
+ endpoints,
91
+ directives
92
+ )
93
+
94
+ assert.deepEqual(Object.keys((await describe(tree, request)).routes), [
95
+ '/any/*',
96
+ '/files/**',
97
+ '/v1.0'
98
+ ])
99
+ })
100
+
101
+ it('should skip an intermediate node, whose route answers in its place', async () => {
102
+ const tree = new Tree(
103
+ trunk([node('/posts', [method('PATCH')], [node('/', [method('PUT')])])]),
104
+ endpoints,
105
+ directives
106
+ )
107
+
108
+ assert.deepEqual(Object.keys((await describe(tree, request)).routes), ['/posts'])
109
+ })
110
+
111
+ it('should omit a resource this caller may reach no method of', async () => {
112
+ const tree = new Tree(
113
+ trunk([
114
+ node('/open', [method('GET')]),
115
+ node('/closed', [method('GET', REFUSE), method('POST', REFUSE)])
116
+ ]),
117
+ endpoints,
118
+ directives
119
+ )
120
+
121
+ assert.deepEqual(Object.keys((await describe(tree, request)).routes), ['/open'])
122
+ })
123
+
124
+ it('should carry only the verbs this caller may reach', async () => {
125
+ const tree = new Tree(
126
+ trunk([node('/pots', [method('GET'), method('POST', REFUSE)])]),
127
+ endpoints,
128
+ directives
129
+ )
130
+
131
+ const { routes: resources } = await describe(tree, request)
132
+
133
+ assert.deepEqual(Object.keys(resources['/pots']), ['GET'])
134
+ })
135
+
136
+ it('should answer the declaration a request would reach where two share a template', async () => {
137
+ // `/a/b` and `/a: { /b: }` are two routes and one template; the more specific matches
138
+ const tree = new Tree(
139
+ trunk([
140
+ node('/a/b', [method('GET', 'nested')]),
141
+ node('/a', [], [node('/b', [method('GET', 'adjacent')])])
142
+ ]),
143
+ endpoints,
144
+ directives
145
+ )
146
+
147
+ const { routes: resources } = await describe(tree, request)
148
+
149
+ assert.deepEqual(Object.keys(resources), ['/a/b'])
150
+ assert.equal(resources['/a/b'].GET.description, 'nested')
151
+ })
152
+
153
+ it('should drop a branch that has expired', async () => {
154
+ process.env.__TESTING_EXPOSITION_BRANCH_TTL = '-1'
155
+
156
+ const tree = new Tree(trunk([]), endpoints, directives)
157
+
158
+ tree.merge(trunk([node('/pots', [method('GET')])]), {
159
+ namespace: 'default',
160
+ component: 'pots'
161
+ })
162
+
163
+ assert.deepEqual(Object.keys((await describe(tree, request)).routes), [])
164
+ })
165
+
166
+ it('should sort, because branches merge in the order their tenants answer', async () => {
167
+ const tree = new Tree(
168
+ trunk([node('/zed', [method('GET')]), node('/alpha', [method('GET')])]),
169
+ endpoints,
170
+ directives
171
+ )
172
+
173
+ assert.deepEqual(Object.keys((await describe(tree, request)).routes), ['/alpha', '/zed'])
174
+ })
175
+ })
@@ -0,0 +1,77 @@
1
+ import { describing } from '../Introspection.js'
2
+ import { template } from '../RPC/names.js'
3
+ import { variables } from '../RTD/segment.js'
4
+ import { guarded, resource, type Described } from '../directives/help/index.js'
5
+ import type * as http from '../HTTP/index.js'
6
+ import type { Tree } from '../RTD/index.js'
7
+ import type { Introspection } from '../Introspection.js'
8
+
9
+ /**
10
+ * Every route in the tree this caller may reach, keyed by the template it answers at, and
11
+ * each described exactly as `OPTIONS` on that path describes it — so a key can be taken
12
+ * from the answer and sent back as a request.
13
+ *
14
+ * A route a name cannot spell is here, unlike at `/.rpc` and `/.mcp`: HTTP addresses a
15
+ * route by its path, so leaving one out would make this disagree with the router. A
16
+ * resource no method of which this caller may reach is not here at all — `OPTIONS` answers
17
+ * `403` to one, and an empty entry would still say it exists.
18
+ *
19
+ * Sorted, because a branch is merged whenever its tenant answers and two replicas would
20
+ * otherwise order the same tree differently. Verbs keep the order they were declared in,
21
+ * which is the order `OPTIONS` answers them in.
22
+ */
23
+ export async function describe(tree: Tree, request: http.Context): Promise<Discovered> {
24
+ const context = describing(request)
25
+ const routes = new Map<string, Mounted>()
26
+
27
+ /*
28
+ * Sequentially: there is no I/O to overlap — an endpoint's own description is read once
29
+ * and memoized — so awaiting the whole tree at once would only allocate every clone of
30
+ * it at the same moment.
31
+ */
32
+ for (const { segments, verb, method } of tree.walk()) {
33
+ const introspection = await method.explain(context, variables(segments))
34
+
35
+ if (introspection === null) continue
36
+
37
+ const route = template(segments)
38
+ let mounted = routes.get(route)
39
+
40
+ if (mounted === undefined) {
41
+ mounted = { described: resource(method.directives), methods: {} }
42
+ routes.set(route, mounted)
43
+ }
44
+
45
+ // two declarations can walk to one template — `/a/b` and `/a: { /b: }` — and the walk
46
+ // is in the order `match` tries them, so the first is the one a request would reach
47
+ mounted.methods[verb] ??= introspection
48
+ }
49
+
50
+ const described: Record<string, Resource> = {}
51
+
52
+ for (const route of Array.from(routes.keys()).sort()) {
53
+ const { described: stated, methods } = routes.get(route)!
54
+
55
+ // what the resource is, beside the methods it serves; a verb is upper case and cannot
56
+ // collide with either key
57
+ described[route] = { ...stated, ...guarded(methods), ...methods }
58
+ }
59
+
60
+ return { routes: described }
61
+ }
62
+
63
+ /** What the tree answers. An object, so that what is said of the whole of it has somewhere to go. */
64
+ export interface Discovered {
65
+ routes: Record<string, Resource>
66
+ }
67
+
68
+ /** What one resource is, and what it serves; a verb is upper case, and nothing else here is. */
69
+ export interface Resource extends Described {
70
+ [verb: string]: unknown
71
+ }
72
+
73
+ /** One route template as the walk found it, before the two are answered as one. */
74
+ interface Mounted {
75
+ described: Described | null
76
+ methods: Record<string, Introspection>
77
+ }
@@ -0,0 +1,23 @@
1
+ import Negotiator from 'negotiator'
2
+ import { types } from '../HTTP/formats/index.js'
3
+ import { DISCOVERY } from '../const.js'
4
+ import type { Context, OutgoingMessage } from '../HTTP/index.js'
5
+
6
+ /** What a browser asks for, and what nothing else here answers with. */
7
+ const HTML = 'text/html'
8
+
9
+ /**
10
+ * Where a browser that asked for the trunk is sent. An application serves what it declares,
11
+ * and `/` is usually not one of those — a person who typed the address into a browser is
12
+ * looking for something to look at, and the page is the only thing here that is one.
13
+ *
14
+ * A client that takes anything is not one: `accept` has to prefer a page over what the
15
+ * gateway answers with, which is what a browser sends and an API client does not.
16
+ */
17
+ export function looking(context: Context): OutgoingMessage | null {
18
+ if (context.request.method !== 'GET' || context.procedural) return null
19
+
20
+ if (new Negotiator(context.request).mediaType([...types, HTML]) !== HTML) return null
21
+
22
+ return { status: 302, headers: new Headers({ location: DISCOVERY + '/' }) }
23
+ }
@@ -52,6 +52,10 @@ export class Endpoint implements RTD.Endpoint {
52
52
  return message
53
53
  }
54
54
 
55
+ public selection(): Record<string, Schema> | null {
56
+ return this.mapping.selection()
57
+ }
58
+
55
59
  public async explain(parameters: RTD.Parameter[]): Promise<Introspection> {
56
60
  this.introspection ??= await this.introspect(parameters)
57
61
 
package/source/Gateway.ts CHANGED
@@ -4,9 +4,11 @@ import { console } from 'openspan'
4
4
  import { Connector } from '@toa.io/core'
5
5
  import type { bindings } from '@toa.io/core/types'
6
6
  import * as http from './HTTP/index.js'
7
- import { MCP, RPC } from './const.js'
7
+ import { DISCOVERY, MCP, RPC } from './const.js'
8
8
  import { rethrow } from './exceptions.js'
9
9
  import { decide } from './Branch.js'
10
+ import { describing } from './Introspection.js'
11
+ import { Explorer, looking } from './Discovery/index.js'
10
12
  import type { Interception } from './Interception.js'
11
13
  import type { Dispatcher } from './RPC/index.js'
12
14
  import type { Server } from './MCP/index.js'
@@ -32,6 +34,9 @@ export class Gateway extends Connector {
32
34
 
33
35
  /** And the same of MCP. */
34
36
  private readonly mcp: Server | null
37
+
38
+ /** The tree, for every route at once. Served always, and so not nullable. */
39
+ private readonly explorer: Explorer
35
40
  private readonly branches = new Map<string, Exposed>()
36
41
  private lastMerge = 0
37
42
  private widestGap = 0
@@ -56,6 +61,7 @@ export class Gateway extends Connector {
56
61
  this.directives = directives
57
62
  this.dispatcher = dispatcher
58
63
  this.mcp = mcp
64
+ this.explorer = new Explorer(tree)
59
65
 
60
66
  this.depends(broadcast)
61
67
  }
@@ -89,9 +95,28 @@ export class Gateway extends Connector {
89
95
  if (this.mcp !== null && context.url.pathname === MCP)
90
96
  return await this.mcp.process(context, route)
91
97
 
98
+ // the page under this prefix is an interceptor's, and has already answered
99
+ if (context.url.pathname === DISCOVERY || context.url.pathname === DISCOVERY + '/')
100
+ return await this.explorer.process(context)
101
+
102
+ // a browser that asked for the trunk of an API is looking for the page, and where the
103
+ // application answers there itself, that is what answers
104
+ if (context.url.pathname === '/' && !this.serves(context)) {
105
+ const page = looking(context)
106
+
107
+ if (page !== null) return page
108
+ }
109
+
92
110
  return await this.route(context)
93
111
  }
94
112
 
113
+ /** Whether the application serves the trunk with the verb the request was made with. */
114
+ private serves(context: http.Context): boolean {
115
+ const match = this.tree.match('/')
116
+
117
+ return match !== null && context.request.method in match.node.methods
118
+ }
119
+
95
120
  /**
96
121
  * One call: everything that needs a node. A request makes one of these, and a request
97
122
  * that carries several calls makes one per call.
@@ -200,14 +225,19 @@ export class Gateway extends Connector {
200
225
  node: Node,
201
226
  parameters: Parameter[]
202
227
  ): Promise<http.OutgoingMessage> {
203
- const body = await node.explain(context, parameters)
204
- const verbs = Object.keys(body)
228
+ const { described, methods } = await node.explain(describing(context), parameters)
229
+ const verbs = Object.keys(methods)
205
230
 
206
231
  // what a caller may not use is not a resource to them, and describing it would be the leak
207
232
  if (verbs.length === 0 && Object.keys(node.methods).length > 0)
208
233
  throw new http.Forbidden()
209
234
 
210
- return { body, headers: new Headers({ allow: verbs.join(', ') }) }
235
+ // what the resource is, beside the methods it serves; a verb is upper case and cannot
236
+ // collide with either key
237
+ return {
238
+ body: { ...described, ...methods },
239
+ headers: new Headers({ allow: verbs.join(', ') })
240
+ }
211
241
  }
212
242
 
213
243
  private async discover(): Promise<void> {
@@ -26,6 +26,13 @@ export class Context {
26
26
  */
27
27
  public readonly procedural: boolean = false
28
28
 
29
+ /**
30
+ * Whether this describes a resource rather than calling one. What builds a describing
31
+ * context says so, and a directive that answers differently to a description than to a
32
+ * call reads it here.
33
+ */
34
+ public readonly exploratory: boolean = false
35
+
29
36
  public readonly pipelines: Pipelines = {
30
37
  body: [],
31
38
  response: []
@@ -1,4 +1,5 @@
1
1
  import type { Remote } from '@toa.io/core'
2
+ import type { Context } from './HTTP/index.js'
2
3
 
3
4
  export interface Introspection {
4
5
  /**
@@ -11,24 +12,42 @@ export interface Introspection {
11
12
 
12
13
  /** what a person is shown where a client lists this method, which a name is not */
13
14
  title?: string
15
+
16
+ /** whether reaching it takes being someone, whoever — `auth:anyone` and its like */
17
+ authenticated?: boolean
18
+
19
+ /** whether reaching it is being the identity it is about — `auth:id` */
20
+ private?: boolean
21
+
22
+ /** whether reaching it takes a role — `auth:role` */
23
+ protected?: boolean
24
+
25
+ /** and whether that role is one of the `system` scope */
26
+ system?: boolean
27
+
28
+ /** whether the route publishes this method to a model — [`mcp:tool`](mcp.md) */
29
+ mcp?: boolean
14
30
  route?: Record<string, Schema>
15
31
  query?: Record<string, Schema>
16
-
17
- /** what a request header carries, which is therefore not the body's to send */
18
- headers?: Record<string, Sourced>
19
32
  input?: Schema
20
33
  output?: Schema
21
34
  errors?: string[]
22
35
  }
23
36
 
24
- /** A property the gateway reads from somewhere else, and the schema it was declared with. */
25
- export interface Sourced {
26
- header: string
27
- [keyword: string]: unknown
28
- }
29
-
30
37
  export type Schema = Awaited<ReturnType<Remote['explain']>>['input']
31
38
 
39
+ /**
40
+ * The same request, as a description of a resource rather than a call to one. A directive
41
+ * that answers differently to the two reads `exploratory` — `Anonymous` is the one that
42
+ * does, because the rule refusing a credential at an `anonymous` route is about a reply a
43
+ * cache would hold, and a description is not one.
44
+ */
45
+ export function describing(context: Context): Context {
46
+ return Object.create(context, {
47
+ exploratory: { value: true, enumerable: true }
48
+ }) as Context
49
+ }
50
+
32
51
  /**
33
52
  * The same, in the order the shape states — whatever order the families that filled it ran
34
53
  * in. What a resource says about itself is read, so it is written the way it is documented.
@@ -45,9 +64,13 @@ export function order(introspection: Introspection): Introspection {
45
64
  const KEYS = [
46
65
  'title',
47
66
  'description',
67
+ 'authenticated',
68
+ 'private',
69
+ 'protected',
70
+ 'system',
71
+ 'mcp',
48
72
  'route',
49
73
  'query',
50
- 'headers',
51
74
  'input',
52
75
  'output',
53
76
  'errors'