@toa.io/extensions.exposition 1.0.0-alpha.287 → 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 +8 -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 +5 -3
  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
@@ -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) => {
@@ -0,0 +1,118 @@
1
+ import assert from 'node:assert'
2
+ import { Help } from './Help.js'
3
+ import { Parameters } from './Parameters.js'
4
+ import type { Introspection, Schema } from '../../Introspection.js'
5
+ import type { Described } from './described.js'
6
+ import type { Context } from '../../HTTP/index.js'
7
+ import type { DirectiveFamily } from '../../RTD/index.js'
8
+
9
+ /** The name the family is declared under, and what a consumer of one asks a method for. */
10
+ export const FAMILY = 'help'
11
+
12
+ export type Directive = Help | Parameters
13
+
14
+ export class Family implements DirectiveFamily<Directive> {
15
+ public readonly name = FAMILY
16
+ public readonly mandatory = false
17
+
18
+ /**
19
+ * What a node says of itself is not said of what is under it: inherited, `help:node`
20
+ * would describe every resource below as the one it was written for. The rest go with
21
+ * it — a sentence about one method is not true of the next, and a route variable a
22
+ * template does not have is not one at all.
23
+ */
24
+ public readonly inherited = false
25
+
26
+ /** What the nearest declaration says of the method, or nothing where none does. */
27
+ public static method(directives: Directive[] | undefined): Help | null {
28
+ return stated(directives, 'method')
29
+ }
30
+
31
+ /** And the same of the resource the method is on. */
32
+ public static node(directives: Directive[] | undefined): Help | null {
33
+ return stated(directives, 'node')
34
+ }
35
+
36
+ // eslint-disable-next-line max-params
37
+ public create(name: string, value: unknown, _: unknown, route: string): Directive {
38
+ if (name === 'node' || name === 'method') return new Help(name, value)
39
+
40
+ assert.ok(name === 'route' || name === 'query', `Unknown directive: help:${name}`)
41
+
42
+ return new Parameters(name, value, route)
43
+ }
44
+
45
+ /** What the route states this method and its parameters are. */
46
+ public explain(
47
+ directives: Directive[],
48
+ _: Context,
49
+ introspection: Introspection
50
+ ): Introspection {
51
+ const help = Family.method(directives)
52
+
53
+ const described: Introspection = {
54
+ ...introspection,
55
+ ...(help?.title === undefined ? {} : { title: help.title }),
56
+ ...(help?.description === undefined ? {} : { description: help.description })
57
+ }
58
+
59
+ // a schema says `title` and `description` of what it describes, which is where these go
60
+ for (const subject of PARAMETERS) {
61
+ const declaration = declared(directives, subject)
62
+
63
+ if (declaration === null) continue
64
+
65
+ const parameters = declaration.parameters
66
+
67
+ for (const [name, stated] of Object.entries(parameters)) {
68
+ const schema = described[subject]?.[name]
69
+
70
+ /*
71
+ * A route variable is one because the template names it, whether or not the
72
+ * operation declares it as input — and only then is it answered. `:id` on an
73
+ * observation is the ordinary case: it is taken by the query, so nothing describes
74
+ * it and the caller is not told it is a path parameter at all.
75
+ *
76
+ * Anything else is described only where it is already answered: a querystring
77
+ * parameter is one only where the mapping takes it, and a segment `map:segments`
78
+ * renames is answered under the property it fills.
79
+ */
80
+ if (schema === undefined && !declaration.variables.includes(name)) continue
81
+
82
+ // written where the shape states it, whatever the schema already said
83
+ const merged = { ...schema } as Record<string, unknown>
84
+
85
+ delete merged.title
86
+ delete merged.description
87
+
88
+ described[subject] ??= {}
89
+ described[subject]![name] = Object.assign(merged, stated) as unknown as Schema
90
+ }
91
+ }
92
+
93
+ return described
94
+ }
95
+ }
96
+
97
+ const PARAMETERS = ['route', 'query'] as const
98
+
99
+ function stated(directives: Directive[] | undefined, subject: string): Help | null {
100
+ const help = directives?.find(
101
+ (directive) => directive instanceof Help && directive.subject === subject
102
+ )
103
+
104
+ return (help as Help | undefined) ?? null
105
+ }
106
+
107
+ function declared(
108
+ directives: Directive[] | undefined,
109
+ subject: string
110
+ ): Parameters | null {
111
+ const found = directives?.find(
112
+ (directive) => directive instanceof Parameters && directive.subject === subject
113
+ )
114
+
115
+ return (found as Parameters | undefined) ?? null
116
+ }
117
+
118
+ export type { Described }
@@ -0,0 +1,227 @@
1
+ import assert from 'node:assert'
2
+ import { describe, it } from 'node:test'
3
+ import { Help } from './Help.js'
4
+ import { Family } from './Family.js'
5
+ import { Parameters } from './Parameters.js'
6
+
7
+ describe('help:method', () => {
8
+ it('should read a bare value as the title, which is the short thing', () => {
9
+ const help = new Help('method', 'Hot pots')
10
+
11
+ assert.strictEqual(help.title, 'Hot pots')
12
+ assert.strictEqual(help.description, undefined)
13
+ })
14
+
15
+ it('should take a description beside it', () => {
16
+ const help = new Help('method', {
17
+ title: 'Hot pots',
18
+ description: 'The pots that are too hot to pour.'
19
+ })
20
+
21
+ assert.strictEqual(help.title, 'Hot pots')
22
+ assert.strictEqual(help.description, 'The pots that are too hot to pour.')
23
+ })
24
+
25
+ it('should take a description alone', () => {
26
+ const help = new Help('method', { description: 'Every pot there is.' })
27
+
28
+ assert.strictEqual(help.title, undefined)
29
+ assert.strictEqual(help.description, 'Every pot there is.')
30
+ })
31
+
32
+ it('should not accept one that says nothing', () => {
33
+ assert.throws(() => new Help('method', {}), /says nothing/)
34
+ })
35
+
36
+ it('should not accept what it does not know', () => {
37
+ assert.throws(() => new Help('method', { summary: 'A pot.' }), /'summary'/)
38
+ })
39
+
40
+ it('should not accept an empty value', () => {
41
+ assert.throws(() => new Help('method', { title: ' ' }), /title cannot be empty/)
42
+ assert.throws(
43
+ () => new Help('method', { description: ' ' }),
44
+ /description cannot be empty/
45
+ )
46
+ })
47
+
48
+ it('should not accept a value that is not one', () => {
49
+ assert.throws(() => new Help('method', ['a']), /the value is a title/)
50
+ })
51
+ })
52
+
53
+ describe('help:node', () => {
54
+ it('should read the value as the title', () => {
55
+ const help = new Help('node', 'Pots')
56
+
57
+ assert.strictEqual(help.title, 'Pots')
58
+ assert.strictEqual(help.description, undefined)
59
+ })
60
+
61
+ it('should take a description beside it, as a method does', () => {
62
+ const help = new Help('node', { title: 'Pots', description: 'What is brewing.' })
63
+
64
+ assert.strictEqual(help.title, 'Pots')
65
+ assert.strictEqual(help.description, 'What is brewing.')
66
+ })
67
+ })
68
+
69
+ describe('help', () => {
70
+ const help = new Family()
71
+
72
+ it('should not be inherited: a node describes itself, not what is under it', () => {
73
+ assert.strictEqual(help.inherited, false)
74
+ })
75
+
76
+ it('should take the nearest declaration of each subject', () => {
77
+ const directives = [
78
+ new Help('method', 'Nearest'),
79
+ new Help('node', 'Resource'),
80
+ new Help('method', 'Further')
81
+ ]
82
+
83
+ assert.strictEqual(Family.method(directives)?.title, 'Nearest')
84
+ assert.strictEqual(Family.node(directives)?.title, 'Resource')
85
+ })
86
+
87
+ it('should say nothing where nothing is declared', () => {
88
+ assert.strictEqual(Family.method(undefined), null)
89
+ assert.strictEqual(Family.node([]), null)
90
+ })
91
+
92
+ it('should describe a method with what the route states', () => {
93
+ const directives = [new Help('method', { title: 'Hot', description: 'Too hot.' })]
94
+
95
+ assert.deepStrictEqual(help.explain(directives, null as never, { errors: ['NO'] }), {
96
+ errors: ['NO'],
97
+ title: 'Hot',
98
+ description: 'Too hot.'
99
+ })
100
+ })
101
+
102
+ it('should leave a method the node describes alone', () => {
103
+ // what the resource is, is not what one of its methods is
104
+ const directives = [new Help('node', 'Pots')]
105
+
106
+ assert.deepStrictEqual(help.explain(directives, null as never, {}), {})
107
+ })
108
+
109
+ it('should refuse a directive it does not know', () => {
110
+ assert.throws(() => help.create('resource', 'Pots'), /Unknown directive/)
111
+ })
112
+ })
113
+
114
+ describe('help:route', () => {
115
+ it('should describe a variable the template names', () => {
116
+ const stated = new Parameters('route', { id: 'The pot' }, '/pots/:id')
117
+
118
+ assert.deepStrictEqual(stated.parameters, { id: { title: 'The pot' } })
119
+ })
120
+
121
+ it('should take a description beside it', () => {
122
+ const stated = new Parameters(
123
+ 'route',
124
+ { id: { title: 'The pot', description: 'Which pot to pour.' } },
125
+ '/pots/:id'
126
+ )
127
+
128
+ assert.deepStrictEqual(stated.parameters.id, {
129
+ title: 'The pot',
130
+ description: 'Which pot to pour.'
131
+ })
132
+ })
133
+
134
+ it('should keep what the template names, which is what may be answered anew', () => {
135
+ const stated = new Parameters('route', { id: 'The pot' }, '/pots/:id')
136
+
137
+ assert.deepStrictEqual(stated.variables, ['id'])
138
+ })
139
+
140
+ it('should not check a name, because the template is not what says them all', () => {
141
+ // `map:segments` answers a variable under the property it fills, and that name is
142
+ // nowhere in the template
143
+ const stated = new Parameters('route', { pot: 'The pot' }, '/pots/:id')
144
+
145
+ assert.deepStrictEqual(stated.parameters, { pot: { title: 'The pot' } })
146
+ })
147
+
148
+ it('should name the rest of a path as the template writes it', () => {
149
+ const stated = new Parameters('route', { '**': 'What is left' }, '/files/**')
150
+
151
+ assert.deepStrictEqual(stated.parameters, { '**': { title: 'What is left' } })
152
+ })
153
+ })
154
+
155
+ describe('help:query', () => {
156
+ it('should describe a parameter, which no template names', () => {
157
+ const stated = new Parameters('query', { since: 'From when' }, '/pots')
158
+
159
+ assert.deepStrictEqual(stated.parameters, { since: { title: 'From when' } })
160
+ })
161
+
162
+ it('should refuse a value that names nothing', () => {
163
+ assert.throws(() => new Parameters('query', 'since', '/pots'), /names each parameter/)
164
+ })
165
+
166
+ it('should refuse one that says nothing', () => {
167
+ assert.throws(
168
+ () => new Parameters('query', { since: {} }, '/pots'),
169
+ /help:query\.since: says nothing/
170
+ )
171
+ })
172
+ })
173
+
174
+ describe('help parameters', () => {
175
+ const help = new Family()
176
+
177
+ it('should say what a parameter is, where its schema is', () => {
178
+ const directives = [
179
+ new Parameters('route', { id: 'The pot' }, '/pots/:id'),
180
+ new Parameters('query', { limit: { description: 'How many.' } }, '/pots/:id')
181
+ ]
182
+
183
+ const explained = help.explain(directives, null as never, {
184
+ route: { id: { type: 'string' } as never },
185
+ query: { limit: { type: 'integer' } as never }
186
+ })
187
+
188
+ assert.deepStrictEqual(explained.route, { id: { type: 'string', title: 'The pot' } })
189
+ assert.deepStrictEqual(explained.query, {
190
+ limit: { type: 'integer', description: 'How many.' }
191
+ })
192
+ })
193
+
194
+ it('should leave a parameter the method does not take alone', () => {
195
+ const directives = [new Parameters('query', { since: 'From when' }, '/pots')]
196
+
197
+ assert.deepStrictEqual(help.explain(directives, null as never, {}), {})
198
+ })
199
+
200
+ it('should answer a route variable an operation does not declare', () => {
201
+ // `:id` on an observation is taken by the query, so nothing else describes it
202
+ const directives = [new Parameters('route', { id: 'The pot' }, '/pots/:id')]
203
+
204
+ assert.deepStrictEqual(help.explain(directives, null as never, {}), {
205
+ route: { id: { title: 'The pot' } }
206
+ })
207
+ })
208
+
209
+ it('should describe a segment a mapping renamed, under the name it answers by', () => {
210
+ const directives = [new Parameters('route', { a: 'Which one' }, '/echo/:first')]
211
+
212
+ const explained = help.explain(directives, null as never, {
213
+ route: { a: { type: 'string' } as never }
214
+ })
215
+
216
+ assert.deepStrictEqual(explained.route, { a: { type: 'string', title: 'Which one' } })
217
+ })
218
+
219
+ it('should not invent a parameter nothing answers', () => {
220
+ const directives = [new Parameters('route', { typo: 'Nobody' }, '/echo/:first')]
221
+
222
+ assert.deepStrictEqual(
223
+ help.explain(directives, null as never, { route: { a: { type: 'string' } as never } }),
224
+ { route: { a: { type: 'string' } } }
225
+ )
226
+ })
227
+ })
@@ -0,0 +1,26 @@
1
+ import { described, type Described } from './described.js'
2
+
3
+ /**
4
+ * What a resource or a method is, in the words an application chooses for it — read by
5
+ * everything that describes one: `OPTIONS`, discovery, and the tools MCP publishes.
6
+ *
7
+ * The operation states what it is too, and that is not this. An operation is written
8
+ * without knowledge of any route, and a method is an operation and a route together — the
9
+ * same operation mounted twice is two methods, and one sentence is not true of both.
10
+ */
11
+ export class Help {
12
+ /** `node` says what the resource is; `method` what one of its methods is. */
13
+ public readonly subject: Subject
14
+ public readonly title: string | undefined
15
+ public readonly description: string | undefined
16
+
17
+ public constructor(subject: Subject, value: unknown) {
18
+ const stated: Described = described(`help:${subject}`, value)
19
+
20
+ this.subject = subject
21
+ this.title = stated.title
22
+ this.description = stated.description
23
+ }
24
+ }
25
+
26
+ export type Subject = 'node' | 'method'
@@ -0,0 +1,47 @@
1
+ import assert from 'node:assert'
2
+ import { segment } from '../../RTD/segment.js'
3
+ import { described, type Described } from './described.js'
4
+
5
+ /**
6
+ * What the parameters of a method are, by name: a route variable, or one of the
7
+ * querystring. Written where the method is, because what a variable means is what the
8
+ * route it is in makes of it.
9
+ *
10
+ * A name is not checked against the template, because the template is not what says them
11
+ * all: `map:segments` answers a variable under the property it fills instead, and that
12
+ * name is nowhere here. What matches nothing is not answered.
13
+ */
14
+ export class Parameters {
15
+ public readonly subject: Subject
16
+ public readonly parameters: Record<string, Described>
17
+
18
+ /** What the route template names, which is a parameter whether an operation takes it or not. */
19
+ public readonly variables: string[]
20
+
21
+ public constructor(subject: Subject, value: unknown, route: string) {
22
+ assert.ok(
23
+ typeof value === 'object' && value !== null && !Array.isArray(value),
24
+ `Directive help:${subject}: the value names each parameter it describes`
25
+ )
26
+
27
+ const parameters: Record<string, Described> = {}
28
+
29
+ for (const [name, stated] of Object.entries(value))
30
+ parameters[name] = described(`help:${subject}.${name}`, stated)
31
+
32
+ this.subject = subject
33
+ this.parameters = parameters
34
+ this.variables = subject === 'route' ? names(route) : []
35
+ }
36
+ }
37
+
38
+ /** The variables the template names, which are what a route parameter may be called. */
39
+ function names(route: string): string[] {
40
+ return segment(route)
41
+ .map((part) =>
42
+ part.fragment !== null ? null : part.wildcard === true ? '**' : part.placeholder
43
+ )
44
+ .filter((name): name is string => name !== null)
45
+ }
46
+
47
+ export type Subject = 'route' | 'query'
@@ -0,0 +1,57 @@
1
+ import assert from 'node:assert'
2
+
3
+ /** What a resource, a method or a parameter says of itself, in the order it is written. */
4
+ export interface Described {
5
+ title?: string
6
+ description?: string
7
+
8
+ /** what its methods are guarded by; see `guarded` */
9
+ authenticated?: boolean
10
+ private?: boolean
11
+ protected?: boolean
12
+ system?: boolean
13
+ }
14
+
15
+ /**
16
+ * A bare value is the title: it is the short thing, and the one a client has somewhere to
17
+ * put — a name is an address and reads as one. A sentence is written as a mapping, beside
18
+ * the title it explains, and either may stand alone.
19
+ */
20
+ export function described(subject: string, value: unknown): Described {
21
+ const stated = typeof value === 'string' ? { title: value } : value
22
+
23
+ assert.ok(
24
+ typeof stated === 'object' && stated !== null && !Array.isArray(stated),
25
+ `Directive ${subject}: the value is a title, or a \`title\` and a \`description\``
26
+ )
27
+
28
+ const { title, description, ...rest } = stated as Record<string, unknown>
29
+
30
+ assert.ok(
31
+ Object.keys(rest).length === 0,
32
+ `Directive ${subject}: unknown ${Object.keys(rest)
33
+ .map((key) => `'${key}'`)
34
+ .join(', ')}`
35
+ )
36
+
37
+ assert.ok(
38
+ title === undefined || (typeof title === 'string' && title.trim().length > 0),
39
+ `Directive ${subject}: a title cannot be empty`
40
+ )
41
+
42
+ assert.ok(
43
+ description === undefined ||
44
+ (typeof description === 'string' && description.trim().length > 0),
45
+ `Directive ${subject}: a description cannot be empty`
46
+ )
47
+
48
+ assert.ok(
49
+ title !== undefined || description !== undefined,
50
+ `Directive ${subject}: says nothing`
51
+ )
52
+
53
+ return {
54
+ ...(title === undefined ? {} : { title: title as string }),
55
+ ...(description === undefined ? {} : { description: description as string })
56
+ }
57
+ }
@@ -0,0 +1,45 @@
1
+ import { Family, FAMILY } from './Family.js'
2
+ import type { Directive } from './Family.js'
3
+ import type { Described } from './described.js'
4
+ import type { Directives } from '../../RTD/index.js'
5
+ import type { Introspection } from '../../Introspection.js'
6
+
7
+ export const help = new Family()
8
+
9
+ /**
10
+ * What the resource a method is on says of itself, or nothing where it says nothing. Read
11
+ * from the method because that is what carries a route's directives; every method of one
12
+ * node carries the same declaration.
13
+ */
14
+ export function resource(directives: Directives): Described | null {
15
+ const stated = Family.node(directives.declared<Directive>(FAMILY))
16
+
17
+ if (stated === null) return null
18
+
19
+ return {
20
+ ...(stated.title === undefined ? {} : { title: stated.title }),
21
+ ...(stated.description === undefined ? {} : { description: stated.description })
22
+ }
23
+ }
24
+
25
+ /**
26
+ * What a resource is, taken from what its methods are: the flags are a method's, and a
27
+ * resource carries whichever of them any of its methods does — which is what a reader picks
28
+ * one icon by.
29
+ */
30
+ export function guarded(methods: Record<string, Introspection>): Described {
31
+ const described: Described = {}
32
+
33
+ for (const introspection of Object.values(methods))
34
+ for (const flag of FLAGS) if (introspection[flag] === true) described[flag] = true
35
+
36
+ return described
37
+ }
38
+
39
+ const FLAGS = ['authenticated', 'private', 'protected', 'system'] as const
40
+
41
+ export { FAMILY, Family } from './Family.js'
42
+ export { Help } from './Help.js'
43
+ export { Parameters } from './Parameters.js'
44
+ export type { Directive } from './Family.js'
45
+ export type { Described } from './described.js'
@@ -8,7 +8,9 @@ import { map } from './map/index.js'
8
8
  import { mcp } from './mcp/index.js'
9
9
  import { req } from './require/index.js'
10
10
  import { flow } from './flow/index.js'
11
+ import { help } from './help/index.js'
11
12
  import { discovery } from './oauth/index.js'
13
+ import { Site } from '../Discovery/index.js'
12
14
  import type { DirectiveFamily } from '../RTD/index.js'
13
15
  import type { Interceptor } from '../Interception.js'
14
16
 
@@ -18,9 +20,14 @@ export const families: DirectiveFamily[] = [
18
20
  cache,
19
21
  map,
20
22
  mcp,
23
+ help,
21
24
  req,
22
25
  flow,
23
26
  octets,
24
27
  dev
25
28
  ]
26
- export const interceptors: Interceptor[] = [cors, discovery]
29
+ /**
30
+ * `cors` first, so a preflight is answered before anything reads the request; the page is
31
+ * last, and claims its own prefix.
32
+ */
33
+ export const interceptors: Interceptor[] = [cors, discovery, new Site()]