@prestack/thread 0.1.3 → 0.1.5

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 (270) hide show
  1. package/CHANGELOG.md +563 -0
  2. package/dist/core/actor.d.ts +1 -1
  3. package/dist/core/actor.js +5 -4
  4. package/dist/core/actor.ts +5 -4
  5. package/dist/core/attach.d.ts +0 -7
  6. package/dist/core/attach.js +38 -67
  7. package/dist/core/attach.ts +34 -80
  8. package/dist/core/chain.js +13 -17
  9. package/dist/core/chain.ts +11 -15
  10. package/dist/core/condition.d.ts +4 -4
  11. package/dist/core/condition.js +2 -2
  12. package/dist/core/descend.d.ts +4 -2
  13. package/dist/core/descend.js +91 -23
  14. package/dist/core/descend.ts +90 -21
  15. package/dist/core/errors/catalogue.d.ts +52 -0
  16. package/dist/{operations/errors/errors.js → core/errors/catalogue.js} +1 -1
  17. package/dist/{operations/errors/errors.ts → core/errors/catalogue.ts} +1 -1
  18. package/dist/{operations/errors/catalog.d.ts → core/errors/error.d.ts} +2 -3
  19. package/dist/{operations/errors/catalog.js → core/errors/error.js} +1 -16
  20. package/dist/{operations/errors/catalog.ts → core/errors/error.ts} +3 -19
  21. package/dist/core/errors/namespaces.d.ts +3 -0
  22. package/dist/core/errors/namespaces.js +15 -0
  23. package/dist/core/errors/namespaces.ts +19 -0
  24. package/dist/core/finding.js +2 -2
  25. package/dist/core/finding.ts +1 -2
  26. package/dist/core/init.d.ts +1 -1
  27. package/dist/core/init.js +51 -5
  28. package/dist/core/init.ts +58 -5
  29. package/dist/core/invoked.d.ts +1 -1
  30. package/dist/core/invoked.js +3 -1
  31. package/dist/core/invoked.ts +5 -2
  32. package/dist/core/round.js +36 -22
  33. package/dist/core/round.ts +26 -9
  34. package/dist/core/rows.d.ts +4 -0
  35. package/dist/core/rows.js +31 -2
  36. package/dist/core/rows.ts +33 -1
  37. package/dist/core/scope.d.ts +1 -0
  38. package/dist/core/scope.js +1 -0
  39. package/dist/core/scope.ts +2 -0
  40. package/dist/core/thread.d.ts +3 -3
  41. package/dist/core/tree.d.ts +4 -1
  42. package/dist/core/tree.js +26 -6
  43. package/dist/core/tree.ts +29 -6
  44. package/dist/core/types.d.ts +3 -3
  45. package/dist/core/types.js +3 -1
  46. package/dist/core/types.ts +2 -1
  47. package/dist/derivation/attach.js +3 -2
  48. package/dist/derivation/attach.ts +2 -1
  49. package/dist/derivation/generators/build.js +6 -6
  50. package/dist/derivation/generators/build.ts +1 -1
  51. package/dist/derivation/generators/check.js +1 -1
  52. package/dist/derivation/generators/determinism.js +1 -1
  53. package/dist/derivation/generators/release.js +60 -47
  54. package/dist/derivation/generators/release.ts +66 -43
  55. package/dist/derivation/release.d.ts +7 -3
  56. package/dist/derivation/release.js +14 -1
  57. package/dist/derivation/release.ts +22 -2
  58. package/dist/integrations/attach.d.ts +4 -0
  59. package/dist/integrations/attach.js +37 -56
  60. package/dist/integrations/attach.ts +54 -60
  61. package/dist/integrations/cli.d.ts +37 -0
  62. package/dist/integrations/cli.js +440 -0
  63. package/dist/integrations/cli.ts +543 -0
  64. package/dist/{core/files.d.ts → integrations/disk.d.ts} +16 -24
  65. package/dist/integrations/disk.js +435 -0
  66. package/dist/integrations/disk.ts +465 -0
  67. package/dist/integrations/generators/read.js +6 -6
  68. package/dist/integrations/generators/read.ts +4 -4
  69. package/dist/integrations/generators/vault.js +10 -11
  70. package/dist/integrations/generators/vault.ts +4 -5
  71. package/dist/{operations/http.d.ts → integrations/https.d.ts} +10 -6
  72. package/dist/integrations/https.js +170 -0
  73. package/dist/integrations/https.ts +216 -0
  74. package/dist/integrations/pglite.d.ts +26 -0
  75. package/dist/integrations/pglite.js +143 -0
  76. package/dist/integrations/pglite.ts +206 -0
  77. package/dist/integrations/secret.d.ts +8 -2
  78. package/dist/integrations/secret.js +3 -5
  79. package/dist/integrations/secret.ts +10 -6
  80. package/dist/integrations/target.d.ts +155 -14
  81. package/dist/integrations/target.js +171 -50
  82. package/dist/integrations/target.ts +377 -61
  83. package/dist/integrations/vault.d.ts +1 -0
  84. package/dist/integrations/vault.js +20 -5
  85. package/dist/integrations/vault.ts +17 -5
  86. package/dist/integrity/attach.js +8 -77
  87. package/dist/integrity/attach.ts +7 -76
  88. package/dist/integrity/check-threads.js +5 -5
  89. package/dist/integrity/check-threads.ts +2 -2
  90. package/dist/integrity/constraint.d.ts +0 -1
  91. package/dist/integrity/constraint.js +267 -366
  92. package/dist/integrity/constraint.ts +267 -375
  93. package/dist/integrity/generators/compiler.js +14 -17
  94. package/dist/integrity/generators/compiler.ts +10 -15
  95. package/dist/integrity/generators/gate.js +15 -15
  96. package/dist/integrity/generators/gate.ts +10 -10
  97. package/dist/integrity/generators/issues.js +7 -7
  98. package/dist/integrity/generators/publish.js +39 -57
  99. package/dist/integrity/generators/publish.ts +44 -55
  100. package/dist/integrity/generators/trial.d.ts +3 -3
  101. package/dist/integrity/generators/trial.js +133 -165
  102. package/dist/integrity/generators/trial.ts +145 -169
  103. package/dist/integrity/lint.js +9 -13
  104. package/dist/integrity/lint.ts +7 -10
  105. package/dist/integrity/lsp/bin.js +5 -4
  106. package/dist/integrity/lsp/bin.ts +3 -1
  107. package/dist/integrity/lsp/diagnostics.js +3 -3
  108. package/dist/integrity/lsp/diagnostics.ts +1 -1
  109. package/dist/integrity/lsp/lint.js +19 -30
  110. package/dist/integrity/lsp/lint.ts +16 -32
  111. package/dist/integrity/lsp/server.js +3 -3
  112. package/dist/integrity/slopnator.d.ts +3 -13
  113. package/dist/integrity/slopnator.js +59 -38
  114. package/dist/integrity/slopnator.ts +54 -38
  115. package/dist/integrity/slopnators/sl_broken_lineage.js +2 -2
  116. package/dist/integrity/slopnators/sl_declared_elsewhere.js +1 -1
  117. package/dist/integrity/slopnators/sl_derived_drift.js +4 -4
  118. package/dist/integrity/slopnators/sl_derived_drift.ts +1 -1
  119. package/dist/integrity/slopnators/sl_door_in_a_node.js +220 -0
  120. package/dist/integrity/slopnators/sl_door_in_a_node.ts +250 -0
  121. package/dist/integrity/slopnators/sl_inert_island.js +10 -8
  122. package/dist/integrity/slopnators/sl_inert_island.ts +8 -6
  123. package/dist/integrity/slopnators/sl_missing_row.js +6 -51
  124. package/dist/integrity/slopnators/sl_missing_row.ts +4 -50
  125. package/dist/integrity/slopnators/sl_no_barrel.js +1 -1
  126. package/dist/integrity/slopnators/sl_no_child_process.js +36 -18
  127. package/dist/integrity/slopnators/sl_no_child_process.ts +44 -18
  128. package/dist/integrity/slopnators/sl_no_dispatch_bypass.js +80 -149
  129. package/dist/integrity/slopnators/sl_no_dispatch_bypass.ts +95 -147
  130. package/dist/integrity/slopnators/sl_no_import_cycle.js +15 -4
  131. package/dist/integrity/slopnators/sl_no_import_cycle.ts +20 -4
  132. package/dist/integrity/slopnators/sl_no_such_command.js +2 -2
  133. package/dist/integrity/slopnators/sl_no_such_command.ts +1 -1
  134. package/dist/integrity/slopnators/sl_outside_an_operation.js +20 -0
  135. package/dist/integrity/slopnators/sl_outside_an_operation.ts +25 -0
  136. package/dist/integrity/slopnators/sl_screen_holds_only_declared.d.ts +2 -0
  137. package/dist/integrity/slopnators/sl_screen_holds_only_declared.js +61 -0
  138. package/dist/integrity/slopnators/sl_screen_holds_only_declared.ts +70 -0
  139. package/dist/integrity/slopnators/sl_secret_literal.js +1 -33
  140. package/dist/integrity/slopnators/sl_secret_literal.ts +1 -33
  141. package/dist/integrity/slopnators/sl_stale_path.js +5 -22
  142. package/dist/integrity/slopnators/sl_stale_path.ts +4 -20
  143. package/dist/integrity/slopnators/sl_twice_meant.js +1 -1
  144. package/dist/integrity/slopnators/sl_twice_parented.js +29 -5
  145. package/dist/integrity/slopnators/sl_twice_parented.ts +28 -4
  146. package/dist/integrity/slopnators/sl_undeclared_crossing.d.ts +4 -1
  147. package/dist/integrity/slopnators/sl_undeclared_crossing.js +426 -62
  148. package/dist/integrity/slopnators/sl_undeclared_crossing.ts +454 -70
  149. package/dist/integrity/slopnators/sl_undeclared_destination.js +59 -25
  150. package/dist/integrity/slopnators/sl_undeclared_destination.ts +69 -21
  151. package/dist/integrity/slopnators/sl_undeclared_list.d.ts +2 -0
  152. package/dist/integrity/slopnators/sl_undeclared_list.js +363 -0
  153. package/dist/integrity/slopnators/sl_undeclared_list.ts +358 -0
  154. package/dist/integrity/slopnators/sl_undeclared_node.js +1 -11
  155. package/dist/integrity/slopnators/sl_undeclared_node.ts +0 -10
  156. package/dist/integrity/slopnators/sl_undeclared_operation.js +111 -29
  157. package/dist/integrity/slopnators/sl_undeclared_operation.ts +134 -26
  158. package/dist/integrity/slopnators/sl_undeclared_variant.js +2 -2
  159. package/dist/integrity/slopnators/sl_undeclared_webhook.d.ts +2 -0
  160. package/dist/integrity/slopnators/sl_undeclared_webhook.js +90 -0
  161. package/dist/integrity/slopnators/sl_undeclared_webhook.ts +108 -0
  162. package/dist/integrity/slopnators/sl_underived_route.d.ts +2 -0
  163. package/dist/integrity/slopnators/sl_underived_route.js +61 -0
  164. package/dist/integrity/slopnators/sl_underived_route.ts +72 -0
  165. package/dist/integrity/slopnators/sl_ungoverned_island.js +3 -3
  166. package/dist/integrity/slopnators/sl_ungoverned_island.ts +1 -1
  167. package/dist/integrity/slopnators/sl_unlisted_program.d.ts +2 -0
  168. package/dist/integrity/slopnators/sl_unlisted_program.js +39 -0
  169. package/dist/integrity/slopnators/sl_unlisted_program.ts +40 -0
  170. package/dist/integrity/slopnators/sl_unnamed_island.js +1 -1
  171. package/dist/integrity/slopnators/sl_unpaired_member.js +3 -3
  172. package/dist/integrity/slopnators/sl_unpaired_member.ts +1 -1
  173. package/dist/integrity/slopnators/sl_unpaired_slopnator.js +4 -4
  174. package/dist/integrity/slopnators/sl_unpaired_slopnator.ts +1 -1
  175. package/dist/integrity/slopnators/sl_unprefixed_member.js +5 -31
  176. package/dist/integrity/slopnators/sl_unprefixed_member.ts +2 -28
  177. package/dist/integrity/slopnators/sl_unthreaded.js +11 -7
  178. package/dist/integrity/slopnators/sl_unthreaded.ts +6 -7
  179. package/dist/integrity/slopnators/sl_untranslated_key.js +2 -25
  180. package/dist/integrity/slopnators/sl_untranslated_key.ts +1 -25
  181. package/dist/integrity/slopnators/sl_unvaulted_secret.d.ts +2 -0
  182. package/dist/integrity/slopnators/sl_unvaulted_secret.js +107 -0
  183. package/dist/integrity/slopnators/sl_unvaulted_secret.ts +132 -0
  184. package/dist/integrity/slopnators/sl_unversioned.js +6 -4
  185. package/dist/integrity/slopnators/sl_unversioned.ts +5 -3
  186. package/dist/integrity/sweep.d.ts +1 -0
  187. package/dist/integrity/sweep.js +105 -42
  188. package/dist/integrity/sweep.ts +115 -42
  189. package/dist/knowledge/attach.js +4 -3
  190. package/dist/knowledge/attach.ts +3 -2
  191. package/dist/knowledge/generators/docsgen.js +8 -6
  192. package/dist/knowledge/generators/docsgen.ts +4 -2
  193. package/dist/knowledge/generators/glossary.js +4 -4
  194. package/dist/knowledge/generators/glossary.ts +1 -1
  195. package/dist/operations/access.d.ts +23 -0
  196. package/dist/operations/access.js +28 -1
  197. package/dist/operations/access.ts +43 -0
  198. package/dist/operations/attach.js +33 -113
  199. package/dist/operations/attach.ts +51 -117
  200. package/dist/operations/capabilities.d.ts +14 -5
  201. package/dist/operations/capabilities.js +13 -4
  202. package/dist/operations/capabilities.ts +27 -8
  203. package/dist/operations/dispatch.d.ts +8 -2
  204. package/dist/operations/dispatch.js +128 -185
  205. package/dist/operations/dispatch.ts +138 -191
  206. package/dist/operations/error-results.d.ts +1 -1
  207. package/dist/operations/error-results.js +2 -2
  208. package/dist/operations/error-results.ts +2 -2
  209. package/dist/operations/generators/capability.d.ts +11 -1
  210. package/dist/operations/generators/capability.js +481 -224
  211. package/dist/operations/generators/capability.ts +562 -233
  212. package/dist/operations/generators/compile.js +66 -39
  213. package/dist/operations/generators/compile.ts +78 -39
  214. package/dist/operations/generators/permits.js +11 -12
  215. package/dist/operations/generators/permits.ts +3 -4
  216. package/dist/operations/generators/seed.d.ts +1 -0
  217. package/dist/operations/generators/seed.js +19 -17
  218. package/dist/operations/generators/seed.ts +22 -14
  219. package/dist/operations/generators/trial.d.ts +6 -8
  220. package/dist/operations/generators/trial.js +200 -70
  221. package/dist/operations/generators/trial.ts +230 -93
  222. package/dist/operations/nodes.d.ts +15 -16
  223. package/dist/operations/nodes.ts +16 -17
  224. package/dist/operations/operations.d.ts +40 -4
  225. package/dist/operations/operations.js +1 -0
  226. package/dist/operations/operations.ts +61 -4
  227. package/dist/operations/result.d.ts +2 -2
  228. package/dist/operations/result.ts +2 -2
  229. package/package.json +1 -4
  230. package/dist/core/answers.d.ts +0 -171
  231. package/dist/core/answers.js +0 -789
  232. package/dist/core/answers.ts +0 -999
  233. package/dist/core/client.d.ts +0 -63
  234. package/dist/core/client.js +0 -249
  235. package/dist/core/client.ts +0 -289
  236. package/dist/core/files.js +0 -171
  237. package/dist/core/files.ts +0 -222
  238. package/dist/integrations/module.d.ts +0 -6
  239. package/dist/integrations/module.js +0 -153
  240. package/dist/integrations/module.ts +0 -191
  241. package/dist/integrity/slopnators/sl_unkept_rows.js +0 -104
  242. package/dist/integrity/slopnators/sl_unkept_rows.ts +0 -111
  243. package/dist/integrity/slopnators/sl_unseamed_reach.js +0 -73
  244. package/dist/integrity/slopnators/sl_unseamed_reach.ts +0 -79
  245. package/dist/operations/cost.d.ts +0 -14
  246. package/dist/operations/cost.js +0 -37
  247. package/dist/operations/cost.ts +0 -58
  248. package/dist/operations/errors/errors.d.ts +0 -52
  249. package/dist/operations/http.js +0 -124
  250. package/dist/operations/http.ts +0 -169
  251. package/dist/operations/memory.d.ts +0 -104
  252. package/dist/operations/memory.js +0 -365
  253. package/dist/operations/memory.ts +0 -470
  254. package/dist/operations/metering.d.ts +0 -55
  255. package/dist/operations/metering.js +0 -247
  256. package/dist/operations/metering.ts +0 -328
  257. package/dist/operations/queue-policy.d.ts +0 -3
  258. package/dist/operations/queue-policy.js +0 -8
  259. package/dist/operations/queue-policy.ts +0 -10
  260. package/dist/operations/seams.d.ts +0 -239
  261. package/dist/operations/seams.js +0 -45
  262. package/dist/operations/seams.ts +0 -364
  263. package/dist/operations/store.d.ts +0 -14
  264. package/dist/operations/store.js +0 -1
  265. package/dist/operations/store.ts +0 -29
  266. /package/dist/{operations → core}/errors/classes.d.ts +0 -0
  267. /package/dist/{operations → core}/errors/classes.js +0 -0
  268. /package/dist/{operations → core}/errors/classes.ts +0 -0
  269. /package/dist/integrity/slopnators/{sl_unkept_rows.d.ts → sl_door_in_a_node.d.ts} +0 -0
  270. /package/dist/integrity/slopnators/{sl_unseamed_reach.d.ts → sl_outside_an_operation.d.ts} +0 -0
@@ -1,34 +1,44 @@
1
- import { existsSync, readdirSync, readFileSync, writeFileSync } from '../../core/files.ts'
1
+ import ts from 'typescript'
2
+
3
+ import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from '../../integrations/disk.ts'
2
4
  import { dirname, resolve } from 'node:path'
3
5
  import { argv, stdout } from 'node:process'
4
6
  import { pathToFileURL } from 'node:url'
5
7
 
6
- import { marked, wordsIn } from '../../core/answers.ts'
7
- import { ROOT, TREE, descend } from '../../core/descend.ts'
8
+ import { ROOT, TREE, descend, forget } from '../../core/descend.ts'
8
9
  import { exit, invoked } from '../../core/invoked.ts'
9
10
  import { named as packageOf } from '../../core/scope.ts'
10
11
  import { PREFIXES } from '../../core/thread.ts'
11
12
 
12
- import type { Secret } from '../../integrations/secret.ts'
13
- import { servesBrowser, type Target } from '../../integrations/target.ts'
13
+ import { secrecyOf, type Secret } from '../../integrations/secret.ts'
14
+ import { reachOf, servesBrowser, standsHere, targetOf, type Call, type Target, type Webhook } from '../../integrations/target.ts'
14
15
 
15
16
  import {
16
17
  CAPABILITIES_AT,
17
18
  CLIENT_AT,
18
- DECLARED_SEAMS_AT,
19
19
  MCP_AT,
20
20
  OPENAPI_AT,
21
+ REACHING_AT,
22
+ ACTOR_AT,
23
+ DEFAULTS_AT,
21
24
  DISPATCHING_AT,
22
- SEAMS_AT,
25
+ inboundAt,
26
+ isSurface,
27
+ ROUTE,
23
28
  routeAt,
24
29
  SURFACES,
25
30
  switched,
31
+ UNSTATED,
26
32
  type Capability,
27
33
  type Surface,
28
34
  } from '../capabilities.ts'
29
35
  import { ANONYMOUS, ENVIRONMENT } from '../../core/actor.ts'
30
36
  import { PERMITS_AT } from '../access.ts'
31
- import { SAID as HANDED, supplies } from '../seams.ts'
37
+ import { DISPATCH_AT } from '../dispatch.ts'
38
+ import { STREAMED, TOLD } from '../../integrations/https.ts'
39
+ import { holdsRow, listBounds, marked, nestedBounds, rowsIn, valuesIn, withField, withRow, withTopField, withoutRow } from '../../core/rows.ts'
40
+ import { said } from '../attach.ts'
41
+ import { MEMBERS_LIST } from '../operations.ts'
32
42
  import { beside, operations, quoted, runtime, types } from './seed.ts'
33
43
 
34
44
  interface Written {
@@ -38,6 +48,13 @@ interface Written {
38
48
 
39
49
  const compare = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0)
40
50
 
51
+ const listedIn = (held: ts.Expression | undefined): readonly string[] =>
52
+ held !== undefined && ts.isArrayLiteralExpression(held)
53
+ ? held.elements.filter((one) => ts.isStringLiteral(one)).map((one) => (one as ts.StringLiteral).text)
54
+ : []
55
+
56
+ const trueIn = (held: ts.Expression | undefined): boolean => held !== undefined && held.kind === ts.SyntaxKind.TrueKeyword
57
+
41
58
  function stable(value: unknown): unknown {
42
59
  if (Array.isArray(value)) return value.map(stable)
43
60
  if (typeof value === 'object' && value !== null) {
@@ -97,17 +114,28 @@ function browsing(requires: readonly string[], targets: readonly Target[], secre
97
114
  return true
98
115
  }
99
116
 
100
- function locally(requires: readonly string[]): boolean {
101
- for (const word of requires) {
102
- if (!supplies(word)) return false
103
- if (word === 'sm_vault') return false
104
- }
105
- return true
117
+ export interface Surfaced {
118
+ readonly live: readonly Surface[]
119
+ readonly stated: boolean
120
+ readonly wrong: readonly string[]
121
+ }
122
+
123
+ export function defaulted(): Surfaced | undefined {
124
+ const at = resolve(ROOT, DEFAULTS_AT)
125
+ if (!existsSync(at)) return undefined
126
+ const row = Object.values(valuesIn(readFileSync(at, 'utf8'))).find(
127
+ (one): one is { readonly live?: unknown } =>
128
+ typeof one === 'object' && one !== null && (one as { readonly at?: unknown }).at === DEFAULTS_AT,
129
+ )
130
+ if (row === undefined || !Array.isArray(row.live)) return { live: UNSTATED, stated: false, wrong: [] }
131
+ const given = row.live.map(String)
132
+ return { live: SURFACES.filter((each) => given.includes(each)), stated: true, wrong: given.filter((each) => !isSurface(each)) }
106
133
  }
107
134
 
108
135
  export async function derive(): Promise<{
109
136
  readonly found: readonly Capability[]
110
137
  readonly refusals: readonly string[]
138
+ readonly live: readonly Surface[]
111
139
  }> {
112
140
  const pass = descend()
113
141
  const known = types(pass)
@@ -117,11 +145,35 @@ export async function derive(): Promise<{
117
145
  const targets = await sheet<Target>('targets.ts', 'TARGETS')
118
146
  const secrets = await sheet<Secret>('secrets.ts', 'SECRETS')
119
147
 
120
- const at = resolve(ROOT, CAPABILITIES_AT)
121
- const already = switched(existsSync(at) ? readFileSync(at, 'utf8') : undefined)
148
+ for (const one of targets) {
149
+ if (reachOf(one.id) !== 'sdk' || (one.declares ?? []).length > 0) continue
150
+ refusals.push(
151
+ `\`${one.id}\` calls a package inside this app, and its row declares no file that does the calling. That file is the one ` +
152
+ 'place the package is imported, and it exports `calls`, holding the function each call names as its path. Give the row ' +
153
+ `one, and the file with it, with \`thread edit ${one.id} --declares <id> --at <path> --describes <sentence> --body <text>\``,
154
+ )
155
+ }
156
+
157
+ const tree = defaulted()
158
+ if (tree === undefined) {
159
+ refusals.push(
160
+ `${DEFAULTS_AT} is not here, so nothing says which surfaces an operation answers on, and working it out anyway would ` +
161
+ `take away every route this app has. \`thread attach ${packageOf('operations')}\` writes it`,
162
+ )
163
+ }
164
+ for (const one of tree?.wrong ?? []) {
165
+ refusals.push(
166
+ `${DEFAULTS_AT} says \`${one}\` is live, and there is no such surface. There are ${String(SURFACES.length)}: ` +
167
+ `${SURFACES.join(', ')}. \`thread create capability edit defaults\` rewrites it`,
168
+ )
169
+ }
122
170
 
123
171
  for (const [id, one] of [...operations(pass, known)].sort((a, b) => compare(a[0], b[0]))) {
124
172
  const stated = pass.stated.get(id)
173
+ const declared = {
174
+ webhooks: listedIn(stated?.fields.get('webhooks')),
175
+ signed: trueIn(stated?.fields.get('signed')),
176
+ }
125
177
  const describes = stated?.describes
126
178
  if (describes === undefined) {
127
179
  refusals.push(`\`${id}\` states no \`describes\`, and a caller told nothing about an operation cannot choose it`)
@@ -148,7 +200,17 @@ export async function derive(): Promise<{
148
200
  continue
149
201
  }
150
202
 
151
- const live = already.get(id) ?? [...SURFACES]
203
+ const off = listedIn(stated?.fields.get('off'))
204
+ const stray = off.filter((each) => !isSurface(each))
205
+ if (stray.length > 0) {
206
+ refusals.push(
207
+ `\`${id}\` turns off ${stray.map((each) => `\`${each}\``).join(', ')}, and there is no such surface. There are ` +
208
+ `${String(SURFACES.length)}: ${SURFACES.join(', ')}. \`thread create capability edit ${id}\` rewrites it`,
209
+ )
210
+ continue
211
+ }
212
+
213
+ const live = (tree?.live ?? UNSTATED).filter((each) => !off.includes(each))
152
214
 
153
215
  found.push({
154
216
  operation: id,
@@ -157,15 +219,66 @@ export async function derive(): Promise<{
157
219
  nodes: one.nodes.map((each) => each.id ?? '').filter((each) => each !== ''),
158
220
  input: schema,
159
221
  live: [...live].sort(compare) as readonly Surface[],
222
+ webhooks: [...declared.webhooks].sort(compare),
223
+ signed: declared.signed,
160
224
  browsable: browsing(one.requires, targets, secrets),
161
- local: browsing(one.requires, targets, secrets) && locally(one.requires),
162
225
  mcp: { name: id, description: describes, inputSchema: schema },
163
226
  cli: { invocation: `${id} --input '<json>'`, describes },
164
227
  api: { method, path: `/operations/${id}` },
165
228
  })
166
229
  }
167
230
 
168
- return { found, refusals }
231
+ for (const one of await sheet<Webhook>('targets.ts', 'WEBHOOKS')) {
232
+ const runs = found.find((each) => each.operation === one.invokes)
233
+ if (runs === undefined) {
234
+ refusals.push(
235
+ `\`${one.id}\` invokes \`${one.invokes}\`, and this app declares no operation by that name. A webhook is a way in ` +
236
+ `to something already declared, so declare that operation or point this webhook at one that exists`,
237
+ )
238
+ continue
239
+ }
240
+ const route = (one.derives ?? []).some((each) => each.at === inboundAt(one.id) && each.by !== undefined && each.runs !== undefined)
241
+ if (!route) {
242
+ refusals.push(
243
+ `\`${one.id}\` derives no route. A webhook is an address anybody may post to, so the file that answers it has to be a row ` +
244
+ `this tree declares: state a \`derives\` on this webhook naming ${inboundAt(one.id)}, with \`by\` and \`runs\` set to ` +
245
+ '`thread capabilities` and `thread capabilities --check`, so what is written there is checked against what writes it',
246
+ )
247
+ }
248
+ if (!runs.webhooks.includes(one.id)) {
249
+ refusals.push(
250
+ `\`${one.id}\` invokes \`${one.invokes}\`, and that operation does not name it back. An operation is run from outside ` +
251
+ `only by a webhook it named, so add ${one.id} to \`webhooks\` on that operation's row in ${TREE}operations.ts, or point this webhook elsewhere`,
252
+ )
253
+ }
254
+ if (runs.signed && (one.arrives === undefined || (one.requires ?? []).length === 0)) {
255
+ refusals.push(
256
+ `\`${one.invokes}\` says it will only be run by a signed webhook, and \`${one.id}\` carries no signature to check. ` +
257
+ `Give this webhook the header its signature arrives on and the secret it is signed with`,
258
+ )
259
+ }
260
+ if (!runs.live.includes('webhook')) {
261
+ refusals.push(
262
+ `\`${one.id}\` invokes \`${one.invokes}\`, and that operation does not answer on the webhook surface. Both halves have to ` +
263
+ `agree before anything outside can run it: webhook has to be live in ${DEFAULTS_AT}, which \`thread create capability edit defaults\` changes, and not in the \`off\` on its row, which \`thread create capability edit ${one.invokes}\` changes`,
264
+ )
265
+ }
266
+ const holds = (one.requires ?? []).filter((each) => secrecyOf(each) !== undefined)
267
+ if (one.arrives !== undefined && holds.length === 0) {
268
+ refusals.push(
269
+ `\`${one.id}\` says a signature arrives on \`${one.arrives}\` and requires no secret to check it against, so every ` +
270
+ `request would be taken on trust. Name the secret it is signed with, or take \`arrives\` off the row`,
271
+ )
272
+ }
273
+ if (one.arrives === undefined && holds.length > 0) {
274
+ refusals.push(
275
+ `\`${one.id}\` requires ${holds.join(', ')} and says no header a signature arrives on, so nothing would ever be checked ` +
276
+ `against it. Say which header carries the signature, or take the secret off the row`,
277
+ )
278
+ }
279
+ }
280
+
281
+ return { found, refusals, live: tree?.live ?? UNSTATED }
169
282
  }
170
283
 
171
284
  function named(): { readonly title: string; readonly version: string } {
@@ -250,11 +363,11 @@ function client(found: readonly Capability[]): string {
250
363
  ].join('\n'),
251
364
  )
252
365
 
253
- const near = found.filter((one) => one.local && compiled(one.operation))
366
+ const near = found.filter((one) => one.browsable && compiled(one.operation))
254
367
 
255
368
  const bound = near.flatMap((one) => [
256
- `export async function ${one.operation}Locally(input: unknown, actor: Actor, seams: Seams): Promise<Result<unknown>> {`,
257
- ` const held = await dispatch(${one.operation}, input, actor, seams)`,
369
+ 'export async function ' + one.operation + 'Locally(input: unknown, actor: Actor): Promise<Result<unknown>> {',
370
+ ` const held = await dispatch(${one.operation}, input, actor, DECLARED, opening, counting)`,
258
371
  ' return held',
259
372
  '}',
260
373
  '',
@@ -266,9 +379,9 @@ function client(found: readonly Capability[]): string {
266
379
  : [
267
380
  `import type { Actor } from '${runtime(CLIENT_AT, '../../core/actor.ts')}'`,
268
381
  `import type { Result } from '${runtime(CLIENT_AT, '../result.ts')}'`,
269
- `import type { Seams } from '${runtime(CLIENT_AT, '../seams.ts')}'`,
270
382
  '',
271
383
  "import { dispatch } from './_dispatch.ts'",
384
+ `import { counting, DECLARED, opening } from '${beside(CLIENT_AT, REACHING_AT)}'`,
272
385
  ...near.map((one) => `import { operation as ${one.operation} } from './${one.operation}.ts'`),
273
386
  '',
274
387
  ]),
@@ -292,12 +405,13 @@ function client(found: readonly Capability[]): string {
292
405
  function route(one: Capability): string {
293
406
  const at = routeAt(one.operation)
294
407
  return [
295
- "import { existsSync } from 'node:fs'",
408
+ `import { existsSync } from ${quoted(runtime(at, '../../integrations/disk.ts'))}`,
296
409
  "import { join } from 'node:path'",
297
410
  '',
411
+ 'import { counting, DECLARED, opening } from ' + quoted(beside(at, REACHING_AT)),
412
+ 'import { actor } from ' + quoted(beside(at, ACTOR_AT)),
413
+ '',
298
414
  "import { dispatch } from '../../../operations/_dispatch.ts'",
299
- `import { actor } from '${beside(at, DECLARED_SEAMS_AT)}'`,
300
- `import { SEAMS } from '${beside(at, SEAMS_AT)}'`,
301
415
  `import { operation } from '../../../operations/${one.operation}.ts'`,
302
416
  '',
303
417
  'const STATUS: Record<string, number> = {',
@@ -320,15 +434,111 @@ function route(one: Capability): string {
320
434
  ' }',
321
435
  ...(one.api.method === 'GET'
322
436
  ? [' const input = Object.fromEntries(new URL(request.url).searchParams)']
323
- : [' const input = await request.json().catch(() => undefined)']),
324
- ' const held = await dispatch(operation, input, await actor(request), SEAMS)',
437
+ : [
438
+ ' const input = await request.json().catch(() => undefined)',
439
+ " if ((request.headers.get('accept') ?? '').includes(" + quoted(STREAMED) + ')) {',
440
+ ' const lines = new TransformStream<string, string>()',
441
+ ' const writing = lines.writable.getWriter()',
442
+ " const sent = (said: unknown): void => { writing.write(JSON.stringify(said) + '\\n').catch(() => undefined) }",
443
+ ' void dispatch(operation, input, await actor(request), DECLARED, opening, counting, (said) => sent({ ' + TOLD + ': said })).then((held) => {',
444
+ ' sent(held)',
445
+ ' return writing.close()',
446
+ ' }).catch(() => undefined)',
447
+ ' return new Response(lines.readable.pipeThrough(new TextEncoderStream()), { headers: { ' + quoted('content-type') + ': ' + quoted(STREAMED) + ' } })',
448
+ ' }',
449
+ ]),
450
+ ' const held = await dispatch(operation, input, await actor(request), DECLARED, opening, counting)',
325
451
  ' return Response.json(held, { status: held.ok ? 200 : (STATUS[held.code] ?? 500) })',
326
452
  '}',
327
453
  '',
328
454
  ].join('\n')
329
455
  }
330
456
 
331
- const SEAM = /export\s+const\s+store\s*:/
457
+ function inbound(one: Webhook): string {
458
+ const at = inboundAt(one.id)
459
+ const secret = (one.requires ?? [])[0]
460
+ const signed = one.arrives !== undefined && secret !== undefined
461
+ const refuse = (why: string): string =>
462
+ " return Response.json({ ok: false, code: " + quoted("denied") + ", error: " + quoted("not allowed") +
463
+ ", because: " + quoted(why) + " }, { status: 403 })"
464
+ return [
465
+ ...(signed ? ["import { createHmac, timingSafeEqual } from " + quoted("node:crypto")] : []),
466
+ "import { existsSync } from " + quoted(runtime(at, "../../integrations/disk.ts")),
467
+ "import { join } from " + quoted("node:path"),
468
+ "",
469
+ "import { counting, DECLARED, opening } from " + quoted(beside(at, REACHING_AT)),
470
+ "",
471
+ "import { dispatch } from " + quoted(beside(at, DISPATCH_AT)),
472
+ "import { operation } from " + quoted(beside(at, "operations/" + one.invokes + ".ts")),
473
+ "",
474
+ "const ARRIVING = " + JSON.stringify(ANONYMOUS),
475
+ "",
476
+ "const PASSED = existsSync(join(process.cwd(), " + quoted(".thread") + ", " + quoted("gate.json") + "))",
477
+ "",
478
+ "export async function POST(request: Request): Promise<Response> {",
479
+ " if (!PASSED) {",
480
+ refuse("this tree has no passing gate, so nothing in it may answer a request. Run thread gate"),
481
+ " }",
482
+ " const raw = await request.text()",
483
+ ...(signed
484
+ ? [
485
+ " const carried = request.headers.get(" + quoted(one.arrives as string) + ")",
486
+ " if (carried === null) {",
487
+ refuse("this webhook is signed and the request carried nothing on " + String(one.arrives)),
488
+ " }",
489
+ " const held = await opening.resolve([" + quoted(secret as string) + "])",
490
+ " const mine = Buffer.from(createHmac(" + quoted("sha256") + ", held[" + quoted(secret as string) +
491
+ "] ?? " + quoted("") + ").update(raw).digest(" + quoted("hex") + "))",
492
+ " const said = Buffer.from(carried)",
493
+ " if (said.length !== mine.length || !timingSafeEqual(said, mine)) {",
494
+ refuse("the signature on this request is not what " + String(secret) + " signs a body with"),
495
+ " }",
496
+ ]
497
+ : []),
498
+ " const input: unknown = JSON.parse(raw)",
499
+ " const answer = await dispatch(operation, input, ARRIVING, DECLARED, opening, counting)",
500
+ " return Response.json(answer, { status: answer.ok ? 200 : 500 })",
501
+ "}",
502
+ "",
503
+ ].join("\n")
504
+ }
505
+
506
+ function declaring(webhooks: readonly Webhook[]): readonly string[] {
507
+ const sheet = resolve(ROOT, TREE + 'targets.ts')
508
+ if (!existsSync(sheet)) return []
509
+ let text = readFileSync(sheet, 'utf8')
510
+ const wrote: string[] = []
511
+ for (const one of webhooks) {
512
+ const id = one.id + '_route'
513
+ const where = listBounds(text, 'WEBHOOKS')
514
+ if (where === undefined) break
515
+ const inside = nestedBounds(text, where, one.id, 'derives')
516
+ if (inside !== undefined && holdsRow(text, inside, id)) continue
517
+ const row = [
518
+ ' {',
519
+ " id: " + quoted(id) + ",",
520
+ " at: " + quoted(inboundAt(one.id)) + ",",
521
+ " describes: " + quoted(
522
+ 'The file that answers what ' + one.id + ' posts. It checks the signature before anything runs and then hands the body ' +
523
+ 'to the same dispatch every other caller reaches, so an inbound event is refused or dispatched and never anything else.',
524
+ ) + ",",
525
+ ' version: 1,',
526
+ " by: 'thread capabilities',",
527
+ " runs: 'thread capabilities --check',",
528
+ ' },',
529
+ ].join('\n')
530
+ const next = inside === undefined
531
+ ? withField(text, where, one.id, 'derives', '[\n' + row + '\n ]')
532
+ : withRow(text, inside, row)
533
+ if (next === undefined || next === text) continue
534
+ text = next
535
+ wrote.push(id)
536
+ }
537
+ if (wrote.length > 0) writeFileSync(sheet, text, 'utf8')
538
+ return wrote
539
+ }
540
+
541
+ const STORED = /export\s+const\s+store\b/
332
542
 
333
543
  const STORES_AT = 'src/stores'
334
544
 
@@ -339,7 +549,7 @@ function kept(): readonly string[] {
339
549
  .filter((one) => one.endsWith('.ts'))
340
550
  .filter((one) => {
341
551
  try {
342
- return SEAM.test(readFileSync(resolve(at, one), 'utf8'))
552
+ return STORED.test(readFileSync(resolve(at, one), 'utf8'))
343
553
  } catch {
344
554
  return false
345
555
  }
@@ -354,169 +564,6 @@ const OPERATIONS_AT = `${TREE}operations.ts`
354
564
 
355
565
  const VAULT_AT = `src/vault.${'ts'}`
356
566
 
357
- function settled(keeping: boolean, stores: readonly string[], ruled: boolean, vaulted: boolean): string {
358
- const named = stores.map((one, at) => ({ from: beside(DECLARED_SEAMS_AT, one), as: `s${String(at)}` }))
359
- const only = named[0]
360
- return [
361
- `import type { Actor, Environment } from '${runtime(DECLARED_SEAMS_AT, '../../core/actor.ts')}'`,
362
- `import type { Seams } from '${runtime(DECLARED_SEAMS_AT, '../seams.ts')}'`,
363
- ...(keeping ? [`import { ENTITIES } from '${beside(DECLARED_SEAMS_AT, ENTITIES_AT)}'`] : []),
364
- ...(ruled ? [`import { permits } from '${beside(DECLARED_SEAMS_AT, PERMITS_AT)}'`] : []),
365
- ...(vaulted ? [`import { vault as opening } from '${beside(DECLARED_SEAMS_AT, VAULT_AT)}'`] : []),
366
- ...(stores.length === 1 && only !== undefined ? [`import { store as kept } from '${only.from}'`] : []),
367
- ...(stores.length > 1 ? named.map((one) => `import { keeps as ${one.as}Keeps, store as ${one.as} } from '${one.from}'`) : []),
368
- '',
369
- ...(keeping
370
- ? [
371
- 'const made = {', " log: { write(level: string, action: string, who: Actor, data: Record<string, unknown>) { console.log(JSON.stringify({ level, action, who, data })) } },", " audit: { async append(who: Actor, record: unknown) { console.log(JSON.stringify({ audit: record, who })) } },", " errors: { async record(who: Actor, failure: unknown) { console.error(JSON.stringify({ failure, who })) } },", '}',
372
- ]
373
- : ['const made = {', " log: { write(level: string, action: string, who: Actor, data: Record<string, unknown>) { console.log(JSON.stringify({ level, action, who, data })) } },", " audit: { async append(who: Actor, record: unknown) { console.log(JSON.stringify({ audit: record, who })) } },", " errors: { async record(who: Actor, failure: unknown) { console.error(JSON.stringify({ failure, who })) } },", '}']),
374
- '',
375
- ...(stores.length > 1
376
- ? [
377
- "const WHERE = new Map<string, Seams['store']>([",
378
- ...named.map((one) => ` ...${one.as}Keeps.map((entity) => [entity, ${one.as}] as const),`),
379
- '])',
380
- '',
381
- 'function at(entity: string) {',
382
- ' const found = WHERE.get(entity)',
383
- " if (found === undefined) throw new Error(entity + ' is not kept in any target this app declares')",
384
- ' return found',
385
- '}',
386
- '',
387
- "const store: Seams['store'] = {",
388
- ' insert: (actor, entity, row) => at(entity).insert(actor, entity, row),',
389
- ' get: (actor, entity, key, fields) => at(entity).get(actor, entity, key, fields),',
390
- ' list: (actor, entity, opts) => at(entity).list(actor, entity, opts),',
391
- ' update: (actor, entity, key, patch, expectedVersion) => at(entity).update(actor, entity, key, patch, expectedVersion),',
392
- ' remove: (actor, entity, key) => at(entity).remove(actor, entity, key),',
393
- '}',
394
- '',
395
- ]
396
- : []),
397
- `const ANONYMOUS: Actor = ${JSON.stringify(ANONYMOUS)}`,
398
- '',
399
- "const http: NonNullable<Seams['http']> = {",
400
- ' async call(request) {',
401
- ' const base = new URL(request.address)',
402
- " if (base.protocol !== 'https:') throw new Error(request.target + ' is reached over ' + base.protocol + ' and an outward call goes over https only')",
403
- ' const stem = base.pathname.length === 1 ? String() : base.pathname',
404
- ' const url = new URL(base.origin + stem + request.path)',
405
- " if (url.origin !== base.origin) throw new Error(request.path + ' resolves to ' + url.origin + ', which is not where ' + request.target + ' is')",
406
- ' const headers: Record<string, string> = { ...request.headers }',
407
- " if (request.contentType !== undefined) headers['content-type'] = request.contentType",
408
- ' const carried = new Uint8Array(request.body === undefined ? 0 : request.body.byteLength)',
409
- ' if (request.body !== undefined) carried.set(request.body)',
410
- ' const began = Date.now()',
411
- ' const sent = JSON.stringify(headers).length + (request.body === undefined ? 0 : request.body.byteLength)',
412
- ' const answered = await fetch(url.toString(), { method: request.method, headers, ...(request.body === undefined ? {} : { body: carried }), signal: AbortSignal.timeout(10000) })',
413
- ' const body = new Uint8Array(await answered.arrayBuffer())',
414
- ' return { status: answered.status, headers: Object.fromEntries(answered.headers.entries()), body, bytesSent: sent, bytesReceived: body.byteLength, durationMs: Date.now() - began }',
415
- ' },',
416
- '}',
417
- '',
418
- 'const shared = globalThis as { __threadSeams?: Seams }',
419
- '',
420
- 'export const DECLARED: Seams = (shared.__threadSeams ??= {',
421
- ' ...made,',
422
- ...(stores.length === 1 ? [' store: kept,'] : []),
423
- ...(stores.length > 1 ? [' store,'] : []),
424
- ' clock: { now: () => Date.now() },',
425
- ' random: { next: Math.random, uuid: () => crypto.randomUUID() },',
426
- ' http,',
427
- ...(ruled ? [' permits: { check: (actor, action, resource) => Promise.resolve(permits(actor, action, resource)) },'] : []),
428
- ...(vaulted ? [' vault: { resolve: opening },'] : []),
429
- '})',
430
- '',
431
- 'function environment(request: Request): Environment {',
432
- ...ENVIRONMENT.map(([fact, header]) => ` const ${fact} = request.headers.get(${quoted(header)})`),
433
- ' return {',
434
- ...ENVIRONMENT.map(([fact]) => ` ...(${fact} === null ? {} : { ${fact} }),`),
435
- ' }',
436
- '}',
437
- '',
438
- ...(keeping
439
- ? [
440
- 'const worn = (held: unknown): readonly string[] =>',
441
- " Array.isArray(held) ? held.filter((one) => typeof one === 'string') : typeof held === 'string' ? [held] : []",
442
- '',
443
- 'function addressed(request: Request, from: string): string | null {',
444
- " if (from.startsWith('cookie:')) {",
445
- " const jar = request.headers.get('cookie') ?? ''",
446
- " const want = `${from.slice('cookie:'.length).trim()}=`",
447
- " const found = jar.split(';').map((one) => one.trim()).find((one) => one.startsWith(want))",
448
- ' return found === undefined ? null : decodeURIComponent(found.slice(want.length))',
449
- ' }',
450
- ' const held = request.headers.get(from)',
451
- ' if (held === null) return null',
452
- " const bare = held.startsWith('Bearer ') ? held.slice('Bearer '.length) : held",
453
- " return bare.trim() === '' ? null : bare.trim()",
454
- '}',
455
- '',
456
- 'export async function actor(request: Request): Promise<Actor> {',
457
- ' const anyone: Actor = { ...ANONYMOUS, ...environment(request) }',
458
- ' const who = ENTITIES.find((one) => one.actor !== undefined)',
459
- ' if (who?.actor === undefined) return anyone',
460
- '',
461
- " const address = (who.must ?? []).flatMap((one) => ('unique' in one ? one.unique : []))[0]",
462
- ' if (address === undefined) return anyone',
463
- '',
464
- ' const held = addressed(request, who.actor.from)',
465
- ' if (held === null) return anyone',
466
- '',
467
- ' const token = who.actor.verifies',
468
- '',
469
- ' const row =',
470
- ' token === undefined',
471
- ' ? await DECLARED.store.get(anyone, who.id, held, [',
472
- ' address,',
473
- ' who.actor.roles,',
474
- ' ...(who.actor.entitlements === undefined ? [] : [who.actor.entitlements]),',
475
- ' ])',
476
- ' : ((await DECLARED.store.list(anyone, who.id, { where: { [token]: held }, limit: 1 }))[0] ?? null)',
477
- ' if (row === null) return anyone',
478
- '',
479
- ' return {',
480
- ' ...anyone,',
481
- ' id: String(row[address] ?? held),',
482
- ' roles: worn(row[who.actor.roles]),',
483
- ' entitlements: who.actor.entitlements === undefined ? [] : worn(row[who.actor.entitlements]),',
484
- ' }',
485
- '}',
486
- '',
487
- ]
488
- : [
489
- 'export const actor = (request: Request): Promise<Actor> => Promise.resolve({ ...ANONYMOUS, ...environment(request) })',
490
- '',
491
- ]),
492
- ].join('\n')
493
- }
494
-
495
- const seeded = (): string =>
496
- [
497
- `import { DECLARED } from '${beside(SEAMS_AT, DECLARED_SEAMS_AT)}'`,
498
- `import type { Seams } from '${runtime(SEAMS_AT, '../seams.ts')}'`,
499
- '',
500
- 'export const SEAMS: Seams = {',
501
- ' ...DECLARED,',
502
- '}',
503
- '',
504
- ].join('\n')
505
-
506
- const width = Math.max(...Object.keys(HANDED).map((one) => one.length))
507
-
508
- const handing = (stores: readonly string[], keeping: boolean, vaulted: boolean): string =>
509
- [
510
- `capabilities: seeded ${SEAMS_AT} — what this app hands its operations, and yours from here.`,
511
- ` It hands on everything ${DECLARED_SEAMS_AT} settles from your declarations: rows go to ` +
512
- (stores.length === 0 ? 'no store yet — no target keeps entities, so an operation that needs one is refused' : stores.join(', ')) +
513
- `, who is asking is ${keeping ? 'read off the entity that names the caller' : 'nobody until an entity names the caller'}, ` +
514
- `calls leave through http${vaulted ? ' with credentials opened by the vault' : ''}.`,
515
- ' Replace a seam, or add one, by naming it in SEAMS. Every seam an operation can be handed:',
516
- ...Object.entries(HANDED).map(([one, says]) => ` ${one.padEnd(width)} ${says}`),
517
- '',
518
- ].join('\n')
519
-
520
567
  const served = (one: Capability): boolean => one.live.includes('api') && compiled(one.operation)
521
568
 
522
569
  const held = (one: Capability): boolean => one.browsable && one.live.includes('api')
@@ -525,8 +572,6 @@ function dispatching(found: readonly Capability[]): string {
525
572
 
526
573
  const reachable = found.filter(served)
527
574
  return [
528
- '/* GENERATED by thread — do not edit. It is derived from what this app declares. */',
529
- '',
530
575
  `declare module '${packageOf('ui')}/ui/checked' {`,
531
576
  ...(reachable.length === 0
532
577
  ? [' interface Dispatching {}']
@@ -569,26 +614,312 @@ function dispatching(found: readonly Capability[]): string {
569
614
  ].join('\n')
570
615
  }
571
616
 
572
- const installed = (): boolean => existsSync(resolve(ROOT, '_threads', 'ui.ts'))
617
+ export function reaching(
618
+ targets: readonly Target[],
619
+ calls: readonly Call[],
620
+ stores: readonly string[],
621
+ vaulted: boolean,
622
+ carried: boolean,
623
+ ): string {
624
+ const taken = (one: Target): readonly string[] =>
625
+ calls.filter((each) => each.of === one.id && one.takes.includes(each.id)).map((each) => each.id.replace(/^cl_/, ''))
626
+
627
+ const rows = targets.map((one) => {
628
+ const mine = taken(one).map((each) => quoted(each))
629
+ return ' readonly ' + one.id + ': ' + (mine.length === 0 ? 'never' : mine.join(' | '))
630
+ })
631
+
632
+ const named = stores.map((one, at) => ({
633
+ from: beside(REACHING_AT, one),
634
+ as: 's' + String(at),
635
+ of: (one.split('/').pop() ?? '').replace(/\.ts$/, ''),
636
+ }))
637
+
638
+ const adapted = targets
639
+ .filter((one) => reachOf(one.id) === 'sdk')
640
+ .flatMap((one, at) => {
641
+ const file = (one.declares ?? [])[0]?.at
642
+ return file === undefined ? [] : [{ from: beside(REACHING_AT, file), as: 'c' + String(at), of: one.id }]
643
+ })
644
+
645
+ const opened = targets
646
+ .map((one) => {
647
+ const reach = reachOf(one.id)
648
+ if (reach === 'sdk') {
649
+ const held = adapted.find((each) => each.of === one.id)
650
+ if (held === undefined) return ''
651
+ const each = calls
652
+ .filter((call) => call.of === one.id && one.takes.includes(call.id))
653
+ .map((call) => quoted(call.id.replace(/^cl_/, '')) + ': ' + held.as + '[' + quoted(call.path) + ']')
654
+ return ' ' + one.id + ': { ' + each.join(', ') + ' },'
655
+ }
656
+ if (reach === 'disk') {
657
+ const through = one.requires.find((each) => reachOf(each) !== undefined)
658
+ return through === undefined ? ' ' + one.id + ': HERE,' : ''
659
+ }
660
+ if (reach === 'cli') {
661
+ const each = calls
662
+ .filter((call) => call.of === one.id && one.takes.includes(call.id))
663
+ .map((call) => quoted(call.id.replace(/^cl_/, '')) + ': ' + quoted(call.path))
664
+ return ' ' + one.id + ': running(' + quoted(one.id.replace(/^cli_/, '')) + ', { ' + each.join(', ') + ' }, process.cwd()),'
665
+ }
666
+ if (reach === 'https') return ' ' + one.id + ': sendingHttp(),'
667
+ const held = named.find((each) => each.of === one.id)
668
+ return held === undefined ? '' : ' ' + one.id + ': ' + held.as + ','
669
+ })
670
+ .filter((each) => each !== '')
671
+
672
+ return [
673
+ 'import { HERE } from ' + quoted(runtime(REACHING_AT, '../../integrations/disk.ts')),
674
+ 'import { running } from ' + quoted(runtime(REACHING_AT, '../../integrations/cli.ts')),
675
+ 'import { sendingHttp } from ' + quoted(runtime(REACHING_AT, '../../integrations/https.ts')),
676
+ 'import type { Counting } from ' +
677
+ quoted(carried ? packageOf('operations') + '/operations/operations' : runtime(REACHING_AT, '../operations.ts')),
678
+ ...named.map((each) => 'import { store as ' + each.as + ' } from ' + quoted(each.from)),
679
+ ...adapted.map((each) => 'import { calls as ' + each.as + ' } from ' + quoted(each.from)),
680
+ ...(vaulted ? ['import { vault } from ' + quoted(beside(REACHING_AT, VAULT_AT))] : []),
681
+ '',
682
+ 'declare module ' +
683
+ quoted(carried ? packageOf('operations') + '/operations/operations' : runtime(REACHING_AT, '../operations.ts')) +
684
+ ' {',
685
+ ...(rows.length === 0 ? [' interface Reaching {}'] : [' interface Reaching {', ...rows, ' }']),
686
+ '}',
687
+ '',
688
+ 'export const DECLARED: Readonly<Record<string, unknown>> = {',
689
+ ...opened,
690
+ '}',
691
+ '',
692
+ vaulted ? 'export const opening = { resolve: vault }' : 'export const opening = undefined',
693
+ '',
694
+ 'export const counting: Counting | undefined = undefined',
695
+ '',
696
+ ].join('\n')
697
+ }
698
+
699
+ const reading = (header: string): string => 'request.headers.get(' + quoted(header) + ')'
700
+
701
+ const CARRIED = ENVIRONMENT.flatMap(([fact, header, fills]) => {
702
+ const taken = header === null ? fills : fills === null ? reading(header) : reading(header) + ' ?? ' + fills
703
+ return taken === null ? [] : [{ fact, taken, always: fills !== null }]
704
+ })
705
+
706
+ function caller(keeping: boolean, kept: string | undefined): string {
707
+ const looking = keeping && kept !== undefined
708
+ return [
709
+ 'import type { Actor, Environment } from ' + quoted(runtime(ACTOR_AT, '../../core/actor.ts')),
710
+ ...(looking ? ['import { ENTITIES } from ' + quoted(beside(ACTOR_AT, ENTITIES_AT))] : []),
711
+ ...(looking ? ['import { DECLARED } from ' + quoted(beside(ACTOR_AT, REACHING_AT))] : []),
712
+ ...(looking ? ['import type { Store } from ' + quoted(runtime(ACTOR_AT, '../../integrations/target.ts'))] : []),
713
+ '',
714
+ 'const ANONYMOUS: Actor = ' + JSON.stringify(ANONYMOUS),
715
+ '',
716
+ 'function environment(request: Request): Environment {',
717
+ ...CARRIED.map((one) => ' const ' + one.fact + ' = ' + one.taken),
718
+ ' return {',
719
+ ...CARRIED.map((one) =>
720
+ one.always ? ' ' + one.fact + ',' : ' ...(' + one.fact + ' === null ? {} : { ' + one.fact + ' }),',
721
+ ),
722
+ ' }',
723
+ '}',
724
+ '',
725
+ ...(looking
726
+ ? [
727
+ 'const worn = (held: unknown): readonly string[] =>',
728
+ ' Array.isArray(held) ? held.filter((one) => typeof one === ' + quoted('string') + ') : typeof held === ' + quoted('string') + ' ? [held] : []',
729
+ '',
730
+ 'function addressed(request: Request, from: string): string | null {',
731
+ ' if (from.startsWith(' + quoted('cookie:') + ')) {',
732
+ ' const jar = request.headers.get(' + quoted('cookie') + ') ?? ' + quoted(''),
733
+ ' const want = from.slice(' + quoted('cookie:') + '.length).trim() + ' + quoted('='),
734
+ ' const found = jar.split(' + quoted(';') + ').map((one) => one.trim()).find((one) => one.startsWith(want))',
735
+ ' return found === undefined ? null : decodeURIComponent(found.slice(want.length))',
736
+ ' }',
737
+ ' const held = request.headers.get(from)',
738
+ ' if (held === null) return null',
739
+ ' const bare = held.startsWith(' + quoted('Bearer ') + ') ? held.slice(' + quoted('Bearer ') + '.length) : held',
740
+ ' return bare.trim() === ' + quoted('') + ' ? null : bare.trim()',
741
+ '}',
742
+ '',
743
+ ]
744
+ : []),
745
+ 'export async function actor(request: Request): Promise<Actor> {',
746
+ ' const anyone: Actor = { ...ANONYMOUS, ...environment(request) }',
747
+ ...(looking
748
+ ? [
749
+ ' const who = ENTITIES.find((one) => one.actor !== undefined)',
750
+ ' if (who?.actor === undefined) return anyone',
751
+ ' const address = (who.must ?? []).flatMap((one) => (' + quoted('unique') + ' in one ? one.unique : []))[0]',
752
+ ' if (address === undefined) return anyone',
753
+ ' const held = addressed(request, who.actor.from)',
754
+ ' if (held === null) return anyone',
755
+ ' const keeper = DECLARED[' + quoted(kept ?? '') + '] as Store | undefined',
756
+ ' if (keeper === undefined) return anyone',
757
+ ' const token = who.actor.verifies',
758
+ ' const row =',
759
+ ' token === undefined',
760
+ ' ? await keeper.get(anyone, who.id, held, [',
761
+ ' address,',
762
+ ' who.actor.roles,',
763
+ ' ...(who.actor.entitlements === undefined ? [] : [who.actor.entitlements]),',
764
+ ' ])',
765
+ ' : ((await keeper.list(anyone, who.id, { where: { [token]: held }, limit: 1 }))[0] ?? null)',
766
+ ' if (row === null) return anyone',
767
+ ' return {',
768
+ ' ...anyone,',
769
+ ' id: String(row[address] ?? held),',
770
+ ' roles: worn(row[who.actor.roles]),',
771
+ ' entitlements: who.actor.entitlements === undefined ? [] : worn(row[who.actor.entitlements]),',
772
+ ' }',
773
+ ]
774
+ : [' return anyone']),
775
+ '}',
776
+ '',
777
+ ].join('\n')
778
+ }
779
+
780
+ const carries = (): boolean => existsSync(resolve(ROOT, 'node_modules', packageOf('operations')))
781
+
782
+ const installed = (): boolean => existsSync(resolve(ROOT, 'node_modules', packageOf('ui')))
573
783
 
574
- const files = (found: readonly Capability[]): readonly Written[] => [
784
+ const files = (
785
+ found: readonly Capability[],
786
+ targets: readonly Target[],
787
+ calls: readonly Call[],
788
+ keeps: string | undefined,
789
+ webhooks: readonly Webhook[] = [],
790
+ ): readonly Written[] => [
575
791
  { at: CAPABILITIES_AT, text: printed(found) },
576
792
  { at: OPENAPI_AT, text: printed(openapi(found)) },
577
793
  { at: MCP_AT, text: printed(mcp(found)) },
578
794
  { at: CLIENT_AT, text: client(found) },
579
795
 
796
+ { at: REACHING_AT, text: reaching(targets, calls, kept(), existsSync(resolve(ROOT, VAULT_AT)), carries()) },
797
+ { at: ACTOR_AT, text: caller(existsSync(resolve(ROOT, ENTITIES_AT)), keeps) },
580
798
  ...(installed() ? [{ at: DISPATCHING_AT, text: dispatching(found) }] : []),
581
799
  ...found.filter(served).map((one) => ({ at: routeAt(one.operation), text: route(one) })),
800
+ ...webhooks
801
+ .filter((one) => found.some((each) => each.operation === one.invokes && each.live.includes('webhook') && each.webhooks.includes(one.id) && compiled(one.invokes)))
802
+ .flatMap((one) => {
803
+ const at = (one.derives ?? []).map((each) => each.at).find((each) => each === inboundAt(one.id))
804
+ return at === undefined ? [] : [{ at, text: inbound(one) }]
805
+ }),
582
806
  ]
583
807
 
808
+ interface Carried {
809
+ readonly live: readonly Surface[]
810
+ readonly off: ReadonlyMap<string, readonly Surface[]>
811
+ }
812
+
813
+ function unmoved(): Carried | undefined {
814
+ const tree = defaulted()
815
+ if (tree === undefined || tree.stated) return undefined
816
+ const at = resolve(ROOT, CAPABILITIES_AT)
817
+ const switches = switched(existsSync(at) ? readFileSync(at, 'utf8') : undefined)
818
+ if (switches.size === 0) return undefined
819
+ const live = SURFACES.filter((each) => [...switches.values()].some((one) => one.includes(each)))
820
+ const off = new Map(
821
+ [...switches]
822
+ .map(([id, one]) => [id, live.filter((each) => !one.includes(each))] as const)
823
+ .filter(([, one]) => one.length > 0),
824
+ )
825
+ const same = off.size === 0 && live.length === UNSTATED.length && live.every((each) => UNSTATED.includes(each))
826
+ return same ? undefined : { live, off }
827
+ }
828
+
829
+ export const asArray = (surfaces: readonly Surface[]): string => `[${surfaces.map((one) => quoted(one)).join(', ')}]`
830
+
831
+ function carry(plan: Carried): readonly string[] {
832
+ const at = resolve(ROOT, DEFAULTS_AT)
833
+ const put = withTopField(readFileSync(at, 'utf8'), 'live', asArray(plan.live))
834
+ if (put === undefined) return []
835
+ writeFileSync(at, put, 'utf8')
836
+ const sheet = resolve(ROOT, OPERATIONS_AT)
837
+ let text = readFileSync(sheet, 'utf8')
838
+ const told = [`${DEFAULTS_AT} is live on ${plan.live.join(', ') || 'nothing'}`]
839
+ for (const [id, off] of plan.off) {
840
+ const where = listBounds(text, MEMBERS_LIST)
841
+ const next = where === undefined ? undefined : withField(text, where, id, 'off', asArray(off))
842
+ if (next === undefined) continue
843
+ text = next
844
+ told.push(`${id} turns off ${off.join(', ')}`)
845
+ }
846
+ writeFileSync(sheet, text, 'utf8')
847
+ return told
848
+ }
849
+
850
+ function routed(found: readonly Capability[], writing: boolean): readonly string[] {
851
+ const sheet = resolve(ROOT, OPERATIONS_AT)
852
+ if (!existsSync(sheet)) return []
853
+ let text = readFileSync(sheet, 'utf8')
854
+ const wrong: string[] = []
855
+ for (const one of found) {
856
+ const id = `${one.operation}_route`
857
+ const row = said(
858
+ { id, at: routeAt(one.operation), describes: ROUTE, by: 'thread capabilities', runs: 'thread capabilities --check' },
859
+ ' ',
860
+ )
861
+ const members = listBounds(text, MEMBERS_LIST)
862
+ const inside = members === undefined ? undefined : nestedBounds(text, members, one.operation, 'derives')
863
+ const standing = inside === undefined ? undefined : rowsIn(text, inside).find((each) => each.id === id)
864
+ const wants = one.live.includes('api')
865
+ if (wants ? standing?.source.trim() === row.trim() : standing === undefined) continue
866
+ wrong.push(
867
+ wants
868
+ ? `${one.operation} answers on api and its row ${standing === undefined ? 'declares no route' : 'says something else about its route'}`
869
+ : `${one.operation} does not answer on api and its row still declares ${routeAt(one.operation)}`,
870
+ )
871
+ if (!writing) continue
872
+ let next = inside !== undefined && standing !== undefined ? withoutRow(text, inside, id) : text
873
+ if (wants) {
874
+ const list = listBounds(next, MEMBERS_LIST)
875
+ const within = list === undefined ? undefined : nestedBounds(next, list, one.operation, 'derives')
876
+ const put =
877
+ within !== undefined
878
+ ? withRow(next, within, row)
879
+ : list === undefined
880
+ ? undefined
881
+ : withField(next, list, one.operation, 'derives', `[\n${row}\n ]`)
882
+ if (put !== undefined) next = put
883
+ }
884
+ text = next
885
+ }
886
+ if (writing && wrong.length > 0) writeFileSync(sheet, text, 'utf8')
887
+ return wrong
888
+ }
889
+
890
+ const unserved = (found: readonly Capability[]): readonly string[] =>
891
+ found
892
+ .filter((one) => !one.live.includes('api') && existsSync(resolve(ROOT, routeAt(one.operation))))
893
+ .map((one) => routeAt(one.operation))
894
+
584
895
  if (invoked(import.meta.url)) {
585
- const { found, refusals } = await derive()
896
+ const words = argv.slice(2).filter((one) => !one.startsWith('-'))
897
+ if (words[0] !== undefined && words[0] !== 'read') {
898
+ console.error(
899
+ 'capabilities: `' + words[0] + '` is not something this command does. `thread capabilities` writes what can be reached, and ' +
900
+ '`thread capabilities read <mark>` prints one. Nothing was written.',
901
+ )
902
+ exit(1)
903
+ }
904
+ if (!argv.includes('--check')) {
905
+ const declared = declaring(await sheet<Webhook>('targets.ts', 'WEBHOOKS'))
906
+ for (const one of declared) console.log(`capabilities: declared ${one} in ${TREE}targets.ts, so the route it writes has a row that says the address was agreed to`)
907
+ if (declared.length > 0) forget()
908
+ const pending = unmoved()
909
+ if (pending !== undefined) {
910
+ console.log(
911
+ `capabilities: carried what ${CAPABILITIES_AT} had switched onto the rows it is worked out from now — ` +
912
+ `${carry(pending).join('; ')}. Nothing in it is set by hand any more.`,
913
+ )
914
+ forget()
915
+ }
916
+ }
917
+ const { found, refusals, live } = await derive()
586
918
  for (const one of refusals) console.error(`capabilities: ${one}`)
587
919
 
588
920
  const mark = (one: Capability): string =>
589
921
  marked([one.operation, one.describes, one.requires.join(','), one.nodes.join(','), one.live.join(',')])
590
922
 
591
- const words = wordsIn(['read'])
592
923
  const verb = words[0]
593
924
 
594
925
  if (verb === 'read') {
@@ -609,7 +940,6 @@ if (invoked(import.meta.url)) {
609
940
  stdout.write(
610
941
  `${one.browsable ? 'may be called from a page' : 'server only — it reaches a target that needs a server secret'}\n`,
611
942
  )
612
- stdout.write(`${one.local ? 'runs where it is called, with nothing sent' : 'reaches a target over the network'}\n`)
613
943
  stdout.write(`api: ${one.api.method} ${one.api.path}\n`)
614
944
  stdout.write(`cli: ${one.cli.invocation}\n`)
615
945
  stdout.write(`mcp: ${one.mcp.name} — ${one.mcp.description}\n`)
@@ -623,43 +953,22 @@ if (invoked(import.meta.url)) {
623
953
 
624
954
  const keeping = existsSync(resolve(ROOT, ENTITIES_AT))
625
955
 
626
- if (existsSync(resolve(ROOT, OPERATIONS_AT))) {
627
- const stores = kept()
628
- const vaulted = existsSync(resolve(ROOT, VAULT_AT))
629
- const text = settled(keeping, stores, existsSync(resolve(ROOT, PERMITS_AT)), vaulted)
630
- const at = resolve(ROOT, DECLARED_SEAMS_AT)
631
- if ((existsSync(at) ? readFileSync(at, 'utf8') : '') !== text) {
632
- if (checking) {
633
- adrift += 1
634
- console.error(
635
- `capabilities: ${DECLARED_SEAMS_AT} is not what the declarations settle. Run \`thread capabilities\` and commit the result.`,
636
- )
637
- } else {
638
- writeFileSync(at, text, 'utf8')
639
- console.log(`capabilities: wrote ${DECLARED_SEAMS_AT} — the seams your targets, entities, grants and secrets settle.`)
640
- }
641
- }
642
-
643
- const seed = resolve(ROOT, SEAMS_AT)
644
- if (!existsSync(seed)) {
645
- if (checking) {
646
- adrift += 1
647
- console.error(
648
- `capabilities: ${SEAMS_AT} is not there, and it is what hands every operation its seams. Run \`thread capabilities\` — it seeds the file once, and from then on it is yours.`,
649
- )
650
- } else {
651
- writeFileSync(seed, seeded(), 'utf8')
652
- stdout.write(handing(stores, keeping, vaulted))
653
- }
654
- }
655
- }
656
-
657
956
  if (found.length === 0 && refusals.length === 0) {
658
957
  console.log('capabilities: this app declares no operation, so nothing is reachable through one yet.')
659
958
  exit(adrift > 0 ? 1 : 0)
660
959
  }
661
960
 
662
- const written = files(found)
961
+ const all = await sheet<Target>('targets.ts', 'TARGETS')
962
+ const people = await sheet<{ readonly id: string; readonly actor?: unknown }>('entities.ts', 'ENTITIES')
963
+ const one = people.find((each) => each.actor !== undefined)
964
+ const where = one === undefined ? undefined : targetOf(one.id, all)
965
+ const written = files(
966
+ found,
967
+ all,
968
+ await sheet<Call>('targets.ts', 'CALLS'),
969
+ where === undefined || typeof where === 'string' ? undefined : where.id,
970
+ await sheet<Webhook>('targets.ts', 'WEBHOOKS'),
971
+ )
663
972
 
664
973
  if (checking) {
665
974
  const drifted = written.filter((one) => {
@@ -671,7 +980,14 @@ if (invoked(import.meta.url)) {
671
980
  `capabilities: ${one.at} is not what the operations derive. Run \`thread capabilities\` and commit the result.`,
672
981
  )
673
982
  }
674
- const off = found.filter((one) => one.live.length < SURFACES.length)
983
+ const unsettled = [
984
+ ...(unmoved() === undefined ? [] : [`${CAPABILITIES_AT} switches surfaces that no row states yet`]),
985
+ ...routed(found, false),
986
+ ...unserved(found).map((at) => `${at} is still on disk to be served, and its operation does not answer on api`),
987
+ ]
988
+ for (const one of unsettled) console.error(`capabilities: ${one}. Run \`thread capabilities\` and commit the result.`)
989
+ adrift += unsettled.length
990
+ const off = found.filter((one) => one.live.length < live.length)
675
991
  console.log(
676
992
  `capabilities: ${String(found.length)} operation(s) reachable, ${String(off.length)} with a surface switched off, ` +
677
993
  `${String(drifted.length + adrift)} finding(s).`,
@@ -686,6 +1002,19 @@ if (invoked(import.meta.url)) {
686
1002
  exit(1)
687
1003
  }
688
1004
 
1005
+ if (!installed()) {
1006
+ const gone = resolve(ROOT, DISPATCHING_AT)
1007
+ if (existsSync(gone)) rmSync(gone)
1008
+ }
1009
+
1010
+ for (const one of routed(found, true)) console.log(`capabilities: ${one}, so ${OPERATIONS_AT} was changed to match`)
1011
+ for (const at of unserved(found)) {
1012
+ rmSync(resolve(ROOT, at))
1013
+ const folder = dirname(resolve(ROOT, at))
1014
+ if (readdirSync(folder).length === 0) rmSync(folder, { recursive: true, force: true })
1015
+ console.log(`capabilities: deleted ${at}, because its operation does not answer on api and nothing may serve it`)
1016
+ }
1017
+
689
1018
  for (const one of written) {
690
1019
  const path = resolve(ROOT, one.at)
691
1020
  writeFileSync(path, one.text)