@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
@@ -8,7 +8,12 @@ import type { Annotations } from './types.js'
8
8
  * A `map:headers` property is not here: a call carries no headers of its own, so there is
9
9
  * nowhere for a model to put one.
10
10
  */
11
- export function input(introspection: Introspection, variables: string[]): object {
11
+ // eslint-disable-next-line max-params
12
+ export function input(
13
+ introspection: Introspection,
14
+ variables: string[],
15
+ selection: Record<string, Schema> | null
16
+ ): object {
12
17
  const properties: Record<string, unknown> = {}
13
18
  const required: string[] = []
14
19
 
@@ -23,15 +28,21 @@ export function input(introspection: Introspection, variables: string[]): object
23
28
  required.push(variable)
24
29
  }
25
30
 
26
- // it is a querystring on the wire, which is nothing a model knows or needs to; what it
27
- // is to a caller is the part of a call that picks what the call is about
28
- if (introspection.query !== undefined)
31
+ /*
32
+ * It is a querystring on the wire, which is nothing a model knows or needs to; what it is
33
+ * to a caller is the part of a call that picks what the call is about. What selects
34
+ * records is stated here and not in the resource's own description, where it would be the
35
+ * same sentence on every queryable resource there is.
36
+ */
37
+ const query = { ...introspection.query, ...selection }
38
+
39
+ if (Object.keys(query).length > 0)
29
40
  properties.query = {
30
41
  type: 'object',
31
42
  description:
32
43
  'Which records the call works on: what to match, in what order, ' +
33
44
  'and how many at once.',
34
- properties: introspection.query
45
+ properties: query
35
46
  }
36
47
 
37
48
  const body = introspection.input as Shape | null | undefined
@@ -4,8 +4,9 @@ import { address, name, split } from '../RPC/names.js'
4
4
  import { METHOD_NOT_FOUND, failure, refusal, response } from './errors.js'
5
5
  import { annotations, input, output } from './schema.js'
6
6
  import { FAMILY, MCP, type Tool as Declaration } from '../directives/mcp/index.js'
7
- import type { Segment } from '../RTD/segment.js'
8
- import type { Parameter, Tree } from '../RTD/index.js'
7
+ import { describing } from '../Introspection.js'
8
+ import { variables } from '../RTD/segment.js'
9
+ import type { Tree } from '../RTD/index.js'
9
10
  import type { Params, Result, Tool } from './types.js'
10
11
 
11
12
  /**
@@ -20,28 +21,26 @@ import type { Params, Result, Tool } from './types.js'
20
21
  *
21
22
  * Sorted, because the revision asks for an order a client can cache on.
22
23
  */
23
- export async function list(tree: Tree, context: http.Context): Promise<Tool[]> {
24
+ export async function list(tree: Tree, request: http.Context): Promise<Tool[]> {
24
25
  const tools: Tool[] = []
25
26
 
26
27
  /*
27
- * Each method is described as the procedure it would be, not as the request asking. What
28
- * refuses a credentialed request at an `anonymous` route does not refuse the call a tool
29
- * makes, and a list that says otherwise disagrees with what `tools/call` then does.
28
+ * A description, not the request asking. What refuses a credentialed request at an
29
+ * `anonymous` route does not refuse the call a tool makes, and a list that says otherwise
30
+ * disagrees with what `tools/call` then does.
30
31
  */
31
- const describing: http.Context = Object.create(context, {
32
- procedural: { value: true, enumerable: true }
33
- })
32
+ const context = describing(request)
34
33
 
35
34
  for (const { segments, verb, method } of tree.walk()) {
36
- if (MCP.published(method.directives.declared<Declaration>(FAMILY)) === null) continue
35
+ if (!MCP.published(method.directives.declared<Declaration>(FAMILY))) continue
37
36
 
38
37
  const named = name(segments, verb)
39
38
 
40
39
  // a route a name cannot spell is a route nothing addresses, here or at `/.rpc`
41
40
  if (named === null) continue
42
41
 
43
- const variables = parameters(segments)
44
- const introspection = await method.explain(describing, variables)
42
+ const params = variables(segments)
43
+ const introspection = await method.explain(context, params)
45
44
 
46
45
  if (introspection === null) continue
47
46
 
@@ -56,7 +55,8 @@ export async function list(tree: Tree, context: http.Context): Promise<Tool[]> {
56
55
  ...(described === undefined ? {} : { description: described }),
57
56
  inputSchema: input(
58
57
  introspection,
59
- variables.map((variable) => variable.name)
58
+ params.map((param) => param.name),
59
+ method.endpoint?.selection() ?? null
60
60
  ),
61
61
  ...(schema === undefined ? {} : { outputSchema: schema }),
62
62
  ...(hints === undefined ? {} : { annotations: hints })
@@ -129,7 +129,7 @@ export interface Scope {
129
129
  function published(tree: Tree, named: string): boolean {
130
130
  for (const { segments, verb, method } of tree.walk())
131
131
  if (name(segments, verb) === named)
132
- return MCP.published(method.directives.declared<Declaration>(FAMILY)) !== null
132
+ return MCP.published(method.directives.declared<Declaration>(FAMILY))
133
133
 
134
134
  return false
135
135
  }
@@ -142,21 +142,3 @@ function result(body: unknown): Result {
142
142
  structuredContent: body
143
143
  }
144
144
  }
145
-
146
- /**
147
- * What a route template takes, by name. Describing has no values for them — a template is
148
- * not a path — and nothing that describes a method reads one.
149
- */
150
- function parameters(segments: Segment[]): Parameter[] {
151
- const params: Parameter[] = []
152
-
153
- for (const segment of segments) {
154
- if (segment.fragment !== null) continue
155
-
156
- if (segment.wildcard === true) params.push({ name: '**', value: '' })
157
- else if (segment.placeholder !== null)
158
- params.push({ name: segment.placeholder, value: '' })
159
- }
160
-
161
- return params
162
- }
package/source/Mapping.ts CHANGED
@@ -24,6 +24,11 @@ export abstract class Mapping {
24
24
  return this.query.explain(introspection)
25
25
  }
26
26
 
27
+ /** What picks the records a call is about; see `Query.selection`. */
28
+ public selection(): Record<string, Schema> | null {
29
+ return this.query.selection()
30
+ }
31
+
27
32
  protected assign(input: any, qs: QueryString): void {
28
33
  if (qs.parameters !== null) {
29
34
  if (typeof input !== 'object' || input === null)
package/source/Query.ts CHANGED
@@ -82,6 +82,11 @@ export class Query {
82
82
  * Only what it actually accepts — a criteria the declaration closes is refused, and so is
83
83
  * a search where none was asked for.
84
84
  */
85
+ /**
86
+ * The querystring parameters this resource declares, which are properties of its own
87
+ * taken out of the input. What selects records is not among them: that is the same of
88
+ * every queryable resource, and `selection` is where it is stated.
89
+ */
85
90
  public explain(introspection: Introspection): Record<string, Schema> | null {
86
91
  let query: Record<string, Schema> | null = null
87
92
 
@@ -96,9 +101,19 @@ export class Query {
96
101
  query[parameter] = schema
97
102
  }
98
103
 
99
- if (!this.queryable) return query
104
+ return query
105
+ }
106
+
107
+ /**
108
+ * What picks the records a call is about, which is the querystring the gateway reads
109
+ * rather than anything the resource declares — the same for every queryable resource,
110
+ * and so not what one says about itself. A procedure states it, because there it is
111
+ * something the caller sends.
112
+ */
113
+ public selection(): Record<string, Schema> | null {
114
+ if (!this.queryable) return null
100
115
 
101
- query ??= {}
116
+ const query: Record<string, Schema> = {}
102
117
 
103
118
  if (!this.closed)
104
119
  query.criteria = keyword(
@@ -29,6 +29,9 @@ export interface Directives {
29
29
  export interface DirectiveFactory {
30
30
  create: (directives: syntax.Directive[], route: string) => Directives
31
31
 
32
+ /** Whether this declaration is carried into the nodes below the one it is on. */
33
+ inheritable: (directive: syntax.Directive) => boolean
34
+
32
35
  /** Runs every family's `preflight`, which is request-scoped and needs no node. */
33
36
  preflight: (context: Context) => Promise<void>
34
37
 
@@ -50,6 +53,13 @@ export interface DirectiveFamily<TDirective = any, TExtension = any> {
50
53
  readonly name: string
51
54
  readonly mandatory: boolean
52
55
 
56
+ /**
57
+ * Whether a declaration applies to everything below the node it is on, as every directive
58
+ * does unless it says otherwise. A family that describes what one node *is* sets this
59
+ * `false`: inherited, it would say the same thing of every resource under it.
60
+ */
61
+ readonly inherited?: boolean
62
+
53
63
  create: (name: string, ...rest: any[]) => TDirective
54
64
 
55
65
  /**
@@ -2,7 +2,7 @@ import { type Context } from './Context.js'
2
2
  import type * as http from '../HTTP/index.js'
3
3
  import type * as syntax from './syntax/index.js'
4
4
  import type * as RTD from './index.js'
5
- import type { Introspection } from '../Introspection.js'
5
+ import type { Introspection, Schema } from '../Introspection.js'
6
6
 
7
7
  export interface Endpoint {
8
8
  call: (
@@ -12,6 +12,13 @@ export interface Endpoint {
12
12
 
13
13
  explain: (parameters: RTD.Parameter[]) => Promise<Introspection>
14
14
 
15
+ /**
16
+ * What picks the records a call is about — `criteria`, `sort` and the rest. Not part of
17
+ * what the resource says about itself: it is the same of every queryable one. A
18
+ * procedure carries it, because there it is something the caller sends.
19
+ */
20
+ selection: () => Record<string, Schema> | null
21
+
15
22
  close: () => Promise<void>
16
23
  }
17
24
 
@@ -3,6 +3,7 @@ import { type Method, type Methods } from './Method.js'
3
3
  import { type Match, type Parameter } from './Match.js'
4
4
  import type { Segment } from './segment.js'
5
5
  import type { Context } from '../HTTP/index.js'
6
+ import { guarded, resource, type Described } from '../directives/help/index.js'
6
7
  import type { Introspection } from '../Introspection.js'
7
8
 
8
9
  export class Node {
@@ -72,11 +73,11 @@ export class Node {
72
73
  for (const route of this.routes) yield* route.walk(segments)
73
74
  }
74
75
 
75
- /** Every method of this node that this caller may reach, in the order they were declared. */
76
- public async explain(
77
- context: Context,
78
- parameters: Parameter[]
79
- ): Promise<Record<string, Introspection>> {
76
+ /**
77
+ * What this resource is, and every method of it this caller may reach, in the order they
78
+ * were declared. The two are answered apart because only the methods are `Allow`.
79
+ */
80
+ public async explain(context: Context, parameters: Parameter[]): Promise<Explained> {
80
81
  const entries = Object.entries(this.methods)
81
82
 
82
83
  const explained = await Promise.all(
@@ -88,7 +89,14 @@ export class Node {
88
89
  for (let i = 0; i < entries.length; i++)
89
90
  if (explained[i] !== null) methods[entries[i][0]] = explained[i]!
90
91
 
91
- return methods
92
+ // every method of a node carries the same declaration, so the first that is there says it
93
+ const stated = entries.length === 0 ? null : resource(entries[0][1].directives)
94
+ const described = { ...stated, ...guarded(methods) }
95
+
96
+ return {
97
+ described: Object.keys(described).length === 0 ? null : described,
98
+ methods
99
+ }
92
100
  }
93
101
 
94
102
  private replace(node: Node): Node[] {
@@ -141,6 +149,12 @@ export class Node {
141
149
  }
142
150
  }
143
151
 
152
+ /** What a resource says of itself, and what it serves. */
153
+ export interface Explained {
154
+ described: Described | null
155
+ methods: Record<string, Introspection>
156
+ }
157
+
144
158
  /** A method, and the route template it is reached by. */
145
159
  export interface Mount {
146
160
  segments: Segment[]
@@ -1,5 +1,6 @@
1
1
  import { console } from 'openspan'
2
2
  import { refusal, template } from '../RPC/names.js'
3
+ import { DISCOVERY } from '../const.js'
3
4
  import { branchTTL, createNode } from './factory.js'
4
5
  import { fragment } from './segment.js'
5
6
  import type { Mount, Node } from './Node.js'
@@ -25,7 +26,7 @@ export class Tree {
25
26
  this.root = node
26
27
  this.trunk = this.createNode(node, PROTECTED)
27
28
 
28
- unnameable(this.trunk)
29
+ announce(this.trunk)
29
30
  }
30
31
 
31
32
  public match(path: string): Match | null {
@@ -48,7 +49,7 @@ export class Tree {
48
49
  public merge(node: syntax.Node, extension: unknown): Node[] {
49
50
  const branch = this.createNode(node, !PROTECTED, extension)
50
51
 
51
- unnameable(branch)
52
+ announce(branch)
52
53
 
53
54
  return this.trunk.merge(branch)
54
55
  }
@@ -87,21 +88,30 @@ export class Tree {
87
88
  }
88
89
 
89
90
  /**
90
- * What is served but cannot be called by name. Said once per route as it is built, because a
91
- * procedure that is missing is otherwise noticed only by the caller who cannot find it.
91
+ * What is served but cannot be reached as it reads. Said once per route as it is built,
92
+ * because either of these is otherwise noticed only by the caller who cannot find it.
92
93
  */
93
- function unnameable(node: Node): void {
94
+ function announce(node: Node): void {
94
95
  const said = new Set<string>()
95
96
 
96
97
  for (const { segments } of node.walk([], TRUNK)) {
97
- const segment = refusal(segments)
98
-
99
- if (segment === null) continue
100
-
101
98
  const route = template(segments)
102
99
 
103
100
  if (said.has(route)) continue
104
101
 
102
+ // the page is served under this prefix, before a request is routed at all
103
+ if (route === DISCOVERY || route.startsWith(DISCOVERY + '/')) {
104
+ said.add(route)
105
+
106
+ console.warn('Route is shadowed by the discovery endpoint', { route })
107
+
108
+ continue
109
+ }
110
+
111
+ const segment = refusal(segments)
112
+
113
+ if (segment === null) continue
114
+
105
115
  said.add(route)
106
116
 
107
117
  console.warn('Route cannot be addressed as a procedure', { route, segment })
@@ -1,7 +1,8 @@
1
+ import assert from 'node:assert'
1
2
  import { BRANCH_TTL } from '../const.js'
2
3
  import { Node, type Properties } from './Node.js'
3
4
  import { Route } from './Route.js'
4
- import { segment } from './segment.js'
5
+ import { fragment, segment } from './segment.js'
5
6
  import { Method, type Methods } from './Method.js'
6
7
  import type { Context } from './Context.js'
7
8
  import type * as syntax from './syntax/index.js'
@@ -10,6 +11,8 @@ export function createNode(node: syntax.Node, context: Context): Node {
10
11
  if (node.isolated === true) context.directives.stack = node.directives
11
12
  else context.directives.stack = node.directives.concat(context.directives.stack)
12
13
 
14
+ described(node, context)
15
+
13
16
  const routes: Route[] = node.routes.map((route) => createRoute(route, context))
14
17
  const methods: Methods = {}
15
18
 
@@ -43,6 +46,16 @@ function createRoute(route: syntax.Route, context: Context): Route {
43
46
 
44
47
  context.path = join(path, route.path)
45
48
 
49
+ /*
50
+ * What a node says of itself is not said of what is under it. Its own `/` is the
51
+ * exception: an intermediate node is never what a path matches, because that route
52
+ * answers in its place — so the two are one resource and what describes it carries.
53
+ */
54
+ if (route.path !== ROOT)
55
+ context.directives.stack = stack.filter((directive) =>
56
+ context.directives.factory.inheritable(directive)
57
+ )
58
+
46
59
  const node = createNode(route.node, context)
47
60
 
48
61
  context.directives.stack = stack // restore
@@ -67,3 +80,26 @@ function createMethod(method: syntax.Method, context: Context): Method {
67
80
 
68
81
  return new Method(endpoint, directives)
69
82
  }
83
+
84
+ /**
85
+ * A resource is what it is described beside: a node with no methods of its own is never
86
+ * what a path answers, so nothing would carry what it says about itself. Refused where the
87
+ * declaration is, rather than left to be noticed by whoever cannot find it in the answer.
88
+ */
89
+ function described(node: syntax.Node, context: Context): void {
90
+ const help = node.directives.find(
91
+ (directive) => directive.family === HELP && directive.name === 'node'
92
+ )
93
+
94
+ if (help === undefined || node.methods.length > 0) return
95
+
96
+ // an intermediate node's `/` answers in its place, and carries what it says
97
+ if (node.routes.some((route) => route.path === ROOT)) return
98
+
99
+ const route = '/' + fragment(context.path).join('/')
100
+
101
+ assert.fail(`Directive help:node: '${route}' serves no methods`)
102
+ }
103
+
104
+ const ROOT = '/'
105
+ const HELP = 'help'
@@ -1,3 +1,5 @@
1
+ import type { Parameter } from './Match.js'
2
+
1
3
  export function segment(path: string): Segment[] {
2
4
  return fragment(path).map(parse)
3
5
  }
@@ -28,3 +30,22 @@ export type Segment =
28
30
  placeholder: string | null
29
31
  wildcard?: boolean
30
32
  }
33
+
34
+ /**
35
+ * What a route template takes, by name. Describing has no values for them — a template is
36
+ * not a path — and nothing that describes a method reads one. A `*` is skipped: it stands
37
+ * for a segment the caller cannot name, so there is nothing to substitute.
38
+ */
39
+ export function variables(segments: Segment[]): Parameter[] {
40
+ const params: Parameter[] = []
41
+
42
+ for (const segment of segments) {
43
+ if (segment.fragment !== null) continue
44
+
45
+ if (segment.wildcard === true) params.push({ name: '**', value: '' })
46
+ else if (segment.placeholder !== null)
47
+ params.push({ name: segment.placeholder, value: '' })
48
+ }
49
+
50
+ return params
51
+ }
package/source/const.ts CHANGED
@@ -17,3 +17,11 @@ export const BATCH = 32
17
17
  * application could choose is a path it could collide with a route of its own.
18
18
  */
19
19
  export const MCP = '/.mcp'
20
+
21
+ /**
22
+ * Where the resource tree and the page that reads it are served. Pinned for the reason
23
+ * `/.rpc` is: a path an application could choose is a path it could collide with a route of
24
+ * its own. Unlike those two it is not annotated — every entry it carries is what `OPTIONS`
25
+ * on that path already answers to the same caller, and what it adds is the enumeration.
26
+ */
27
+ export const DISCOVERY = '/.discovery'
@@ -4,8 +4,16 @@ import assert from 'node:assert/strict'
4
4
  import { Anonymous } from './Anonymous.js'
5
5
  import type { Context } from './types.js'
6
6
 
7
- const context = (headers: Record<string, string>, procedural = false): Context =>
8
- ({ request: { headers }, procedural }) as unknown as Context
7
+ const context = (
8
+ headers: Record<string, string>,
9
+ flags: { procedural?: boolean; exploratory?: boolean } = {}
10
+ ): Context =>
11
+ ({
12
+ request: { headers },
13
+ procedural: false,
14
+ exploratory: false,
15
+ ...flags
16
+ }) as unknown as Context
9
17
 
10
18
  describe('anonymous', () => {
11
19
  it('should admit a request that presents nothing', () => {
@@ -22,14 +30,28 @@ describe('anonymous', () => {
22
30
 
23
31
  it('should admit a procedure whatever the request presented', () => {
24
32
  // what a procedure answers is a value in an envelope, and the envelope is `no-store`
25
- const procedural = context({ authorization: 'Token x' }, true)
33
+ const procedural = context({ authorization: 'Token x' }, { procedural: true })
26
34
 
27
35
  assert.equal(new Anonymous(true).authorize(null, procedural), true)
28
36
  })
29
37
 
38
+ it('should admit a description whatever the request presented', () => {
39
+ // a description is not the reply a cache would hold, and varies by who asked in any case
40
+ const exploratory = context({ authorization: 'Token x' }, { exploratory: true })
41
+
42
+ assert.equal(new Anonymous(true).authorize(null, exploratory), true)
43
+ })
44
+
30
45
  it('should refuse where it admits nobody', () => {
31
46
  assert.equal(new Anonymous(false).authorize(null, context({})), false)
32
- assert.equal(new Anonymous(false).authorize(null, context({}, true)), false)
47
+ assert.equal(
48
+ new Anonymous(false).authorize(null, context({}, { procedural: true })),
49
+ false
50
+ )
51
+ assert.equal(
52
+ new Anonymous(false).authorize(null, context({}, { exploratory: true })),
53
+ false
54
+ )
33
55
  })
34
56
 
35
57
  it('should describe a method as it authorizes one', () => {
@@ -37,7 +59,11 @@ describe('anonymous', () => {
37
59
 
38
60
  assert.equal(directive.admits(null, context({ authorization: 'Token x' })), false)
39
61
  assert.equal(
40
- directive.admits(null, context({ authorization: 'Token x' }, true)),
62
+ directive.admits(null, context({ authorization: 'Token x' }, { procedural: true })),
63
+ true
64
+ )
65
+ assert.equal(
66
+ directive.admits(null, context({ authorization: 'Token x' }, { exploratory: true })),
41
67
  true
42
68
  )
43
69
  })
@@ -9,11 +9,13 @@ export class Anonymous implements Directive {
9
9
 
10
10
  /**
11
11
  * A credential refuses, because it would make the reply uncacheable — which is the whole
12
- * of the rule, and none of it applies to a procedure: what a procedure answers is a value
13
- * in an envelope, and the envelope is the one thing that is cached or not.
12
+ * of the rule, and none of it applies to a procedure, nor to a description. What a
13
+ * procedure answers is a value in an envelope, and the envelope is the one thing that is
14
+ * cached or not; what a description answers is what the resource is, which is not the
15
+ * reply a cache would hold and varies by who asked in any case.
14
16
  */
15
17
  public authorize(_: any, context: Context): boolean {
16
- if (context.procedural) return this.allow
18
+ if (context.procedural || context.exploratory) return this.allow
17
19
 
18
20
  return 'authorization' in context.request.headers ? false : this.allow
19
21
  }
@@ -1,4 +1,5 @@
1
1
  import { type Directive, type Context } from './types.js'
2
+ import type { Introspection } from '../../Introspection.js'
2
3
 
3
4
  export class Anyone implements Directive {
4
5
  private readonly allow: boolean
@@ -14,4 +15,9 @@ export class Anyone implements Directive {
14
15
  public admits(_: any, context: Context): boolean {
15
16
  return this.authorize(_, context)
16
17
  }
18
+
19
+ /** Whoever asks, as long as they are somebody. */
20
+ public describe(introspection: Introspection): Introspection {
21
+ return this.allow ? { ...introspection, authenticated: true } : introspection
22
+ }
17
23
  }
@@ -16,6 +16,11 @@ export class Assert implements Directive {
16
16
  this.disabled = !enabled
17
17
  }
18
18
 
19
+ /** It admits nobody: it is there to require a credential, not to authorize one. */
20
+ public admits(): boolean {
21
+ return false
22
+ }
23
+
19
24
  public async authorize(identity: Identity | null, context: Context): Promise<boolean> {
20
25
  if (!this.disabled) await this.incept(context, identity)
21
26
 
@@ -28,11 +28,14 @@ export class Delegate implements Directive {
28
28
  return identity !== null
29
29
  }
30
30
 
31
- /** The property it embeds is the identity's, so a caller has nothing to put there. */
31
+ /**
32
+ * It takes an identity, and the property it embeds is that identity's — so a caller has
33
+ * nothing to put there.
34
+ */
32
35
  public describe(introspection: Introspection): Introspection {
33
36
  take(introspection, this.property)
34
37
 
35
- return introspection
38
+ return { ...introspection, authenticated: true }
36
39
  }
37
40
 
38
41
  private embed(body: unknown, identity: Identity): Record<string, unknown> {
@@ -1,5 +1,6 @@
1
1
  import assert from 'node:assert'
2
2
  import type { Directive, Identity, Context } from './types.js'
3
+ import type { Introspection } from '../../Introspection.js'
3
4
  import type { Parameter } from '../../RTD/index.js'
4
5
 
5
6
  export class Federation implements Directive {
@@ -16,6 +17,16 @@ export class Federation implements Directive {
16
17
  )
17
18
  }
18
19
 
20
+ /** Which claims it takes needs the request; that it takes an identity does not. */
21
+ public admits(identity: Identity | null): boolean | undefined {
22
+ return identity === null ? false : undefined
23
+ }
24
+
25
+ /** Which claims is the application's business; that it takes an identity is the caller's. */
26
+ public describe(introspection: Introspection): Introspection {
27
+ return { ...introspection, authenticated: true }
28
+ }
29
+
19
30
  public authorize(
20
31
  identity: Identity | null,
21
32
  context: Context,
@@ -1,5 +1,6 @@
1
1
  import { type Parameter } from '../../RTD/index.js'
2
2
  import { type Directive, type Identity } from './types.js'
3
+ import type { Introspection } from '../../Introspection.js'
3
4
 
4
5
  export class Id implements Directive {
5
6
  private readonly parameter: string
@@ -8,6 +9,20 @@ export class Id implements Directive {
8
9
  this.parameter = parameter
9
10
  }
10
11
 
12
+ /**
13
+ * Whether it is *this* identity's cannot be told from a description, which has no route
14
+ * variable to read — but that there must be an identity at all can, and a caller with
15
+ * none is refused whatever the value would have been.
16
+ */
17
+ public admits(identity: Identity | null): boolean | undefined {
18
+ return identity === null ? false : undefined
19
+ }
20
+
21
+ /** Reaching it is being the identity it is about, which is what `private` says. */
22
+ public describe(introspection: Introspection): Introspection {
23
+ return { ...introspection, private: true }
24
+ }
25
+
11
26
  public authorize(
12
27
  identity: Identity | null,
13
28
  _: unknown,
@@ -12,6 +12,11 @@ export class Input implements Directive {
12
12
  )
13
13
  }
14
14
 
15
+ /** It admits nobody: it says what a body may carry, not who may send one. */
16
+ public admits(): boolean {
17
+ return false
18
+ }
19
+
15
20
  public async authorize(
16
21
  identity: Identity | null,
17
22
  context: Context,