@smthrs/kernel 0.0.0-stage → 1.0.0-rc.3

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 (267) hide show
  1. package/CHANGELOG.md +206 -0
  2. package/LICENSE +21 -0
  3. package/README.md +154 -2
  4. package/dist/cjs/CapabilitySet.d.ts +6 -0
  5. package/dist/cjs/CapabilitySet.d.ts.map +1 -0
  6. package/dist/cjs/CapabilitySet.js +19 -0
  7. package/dist/cjs/CapabilitySet.js.map +7 -0
  8. package/dist/cjs/ChildProcessEnvironment.d.ts +60 -0
  9. package/dist/cjs/ChildProcessEnvironment.d.ts.map +1 -0
  10. package/dist/cjs/ChildProcessEnvironment.js +61 -0
  11. package/dist/cjs/ChildProcessEnvironment.js.map +7 -0
  12. package/dist/cjs/ChildProcessSpawner.d.ts +100 -0
  13. package/dist/cjs/ChildProcessSpawner.d.ts.map +1 -0
  14. package/dist/cjs/ChildProcessSpawner.js +291 -0
  15. package/dist/cjs/ChildProcessSpawner.js.map +7 -0
  16. package/dist/cjs/CommandLine.d.ts +173 -0
  17. package/dist/cjs/CommandLine.d.ts.map +1 -0
  18. package/dist/cjs/CommandLine.js +54 -0
  19. package/dist/cjs/CommandLine.js.map +7 -0
  20. package/dist/cjs/ContainedSpawner.d.ts +135 -0
  21. package/dist/cjs/ContainedSpawner.d.ts.map +1 -0
  22. package/dist/cjs/ContainedSpawner.js +141 -0
  23. package/dist/cjs/ContainedSpawner.js.map +7 -0
  24. package/dist/cjs/FileSystem.d.ts +452 -0
  25. package/dist/cjs/FileSystem.d.ts.map +1 -0
  26. package/dist/cjs/FileSystem.js +890 -0
  27. package/dist/cjs/FileSystem.js.map +7 -0
  28. package/dist/cjs/FileSystemBatch.d.ts +94 -0
  29. package/dist/cjs/FileSystemBatch.d.ts.map +1 -0
  30. package/dist/cjs/FileSystemBatch.js +31 -0
  31. package/dist/cjs/FileSystemBatch.js.map +7 -0
  32. package/dist/cjs/GrantEvent.d.ts +240 -0
  33. package/dist/cjs/GrantEvent.d.ts.map +1 -0
  34. package/dist/cjs/GrantEvent.js +115 -0
  35. package/dist/cjs/GrantEvent.js.map +7 -0
  36. package/dist/cjs/GrantStore.d.ts +270 -0
  37. package/dist/cjs/GrantStore.d.ts.map +1 -0
  38. package/dist/cjs/GrantStore.js +857 -0
  39. package/dist/cjs/GrantStore.js.map +7 -0
  40. package/dist/cjs/HostError.d.ts +36 -0
  41. package/dist/cjs/HostError.d.ts.map +1 -0
  42. package/dist/cjs/HostError.js +17 -0
  43. package/dist/cjs/HostError.js.map +7 -0
  44. package/dist/cjs/HostServices.d.ts +101 -0
  45. package/dist/cjs/HostServices.d.ts.map +1 -0
  46. package/dist/cjs/HostServices.js +66 -0
  47. package/dist/cjs/HostServices.js.map +7 -0
  48. package/dist/cjs/HttpClient.d.ts +177 -0
  49. package/dist/cjs/HttpClient.d.ts.map +1 -0
  50. package/dist/cjs/HttpClient.js +217 -0
  51. package/dist/cjs/HttpClient.js.map +7 -0
  52. package/dist/cjs/Jj.d.ts +67 -0
  53. package/dist/cjs/Jj.d.ts.map +1 -0
  54. package/dist/cjs/Jj.js +118 -0
  55. package/dist/cjs/Jj.js.map +7 -0
  56. package/dist/cjs/JournalGrantStore.d.ts +65 -0
  57. package/dist/cjs/JournalGrantStore.d.ts.map +1 -0
  58. package/dist/cjs/JournalGrantStore.js +354 -0
  59. package/dist/cjs/JournalGrantStore.js.map +7 -0
  60. package/dist/cjs/Path.d.ts +37 -0
  61. package/dist/cjs/Path.d.ts.map +1 -0
  62. package/dist/cjs/Path.js +46 -0
  63. package/dist/cjs/Path.js.map +7 -0
  64. package/dist/cjs/ProcessConfinement.d.ts +55 -0
  65. package/dist/cjs/ProcessConfinement.d.ts.map +1 -0
  66. package/dist/cjs/ProcessConfinement.js +94 -0
  67. package/dist/cjs/ProcessConfinement.js.map +7 -0
  68. package/dist/cjs/ProcessLedger.d.ts +260 -0
  69. package/dist/cjs/ProcessLedger.d.ts.map +1 -0
  70. package/dist/cjs/ProcessLedger.js +190 -0
  71. package/dist/cjs/ProcessLedger.js.map +7 -0
  72. package/dist/cjs/Rooted.d.ts +53 -0
  73. package/dist/cjs/Rooted.d.ts.map +1 -0
  74. package/dist/cjs/Rooted.js +101 -0
  75. package/dist/cjs/Rooted.js.map +7 -0
  76. package/dist/cjs/Unreachable.d.ts +27 -0
  77. package/dist/cjs/Unreachable.d.ts.map +1 -0
  78. package/dist/cjs/Unreachable.js +43 -0
  79. package/dist/cjs/Unreachable.js.map +7 -0
  80. package/dist/cjs/Workspace.d.ts +62 -0
  81. package/dist/cjs/Workspace.d.ts.map +1 -0
  82. package/dist/cjs/Workspace.js +35 -0
  83. package/dist/cjs/Workspace.js.map +7 -0
  84. package/dist/cjs/index.d.ts +161 -0
  85. package/dist/cjs/index.d.ts.map +1 -0
  86. package/dist/cjs/index.js +73 -0
  87. package/dist/cjs/index.js.map +7 -0
  88. package/dist/cjs/internal/Containment.d.ts +24 -0
  89. package/dist/cjs/internal/Containment.d.ts.map +1 -0
  90. package/dist/cjs/internal/Containment.js +30 -0
  91. package/dist/cjs/internal/Containment.js.map +7 -0
  92. package/dist/cjs/internal/Pipeline.d.ts +18 -0
  93. package/dist/cjs/internal/Pipeline.d.ts.map +1 -0
  94. package/dist/cjs/internal/Pipeline.js +90 -0
  95. package/dist/cjs/internal/Pipeline.js.map +7 -0
  96. package/dist/cjs/internal/makeCapability.d.ts +16 -0
  97. package/dist/cjs/internal/makeCapability.d.ts.map +1 -0
  98. package/dist/cjs/internal/makeCapability.js +44 -0
  99. package/dist/cjs/internal/makeCapability.js.map +7 -0
  100. package/dist/cjs/package.json +1 -0
  101. package/dist/cjs/test/FileSystemBatchContract.d.ts +15 -0
  102. package/dist/cjs/test/FileSystemBatchContract.d.ts.map +1 -0
  103. package/dist/cjs/test/FileSystemBatchContract.js +76 -0
  104. package/dist/cjs/test/FileSystemBatchContract.js.map +7 -0
  105. package/dist/cjs/test/HostContract.d.ts +225 -0
  106. package/dist/cjs/test/HostContract.d.ts.map +1 -0
  107. package/dist/cjs/test/HostContract.js +489 -0
  108. package/dist/cjs/test/HostContract.js.map +7 -0
  109. package/dist/cjs/test/TestGrantStore.d.ts +31 -0
  110. package/dist/cjs/test/TestGrantStore.d.ts.map +1 -0
  111. package/dist/cjs/test/TestGrantStore.js +65 -0
  112. package/dist/cjs/test/TestGrantStore.js.map +7 -0
  113. package/dist/esm/CapabilitySet.d.ts +6 -0
  114. package/dist/esm/CapabilitySet.d.ts.map +1 -0
  115. package/dist/esm/CapabilitySet.js +6 -0
  116. package/dist/esm/CapabilitySet.js.map +1 -0
  117. package/dist/esm/ChildProcessEnvironment.d.ts +60 -0
  118. package/dist/esm/ChildProcessEnvironment.d.ts.map +1 -0
  119. package/dist/esm/ChildProcessEnvironment.js +91 -0
  120. package/dist/esm/ChildProcessEnvironment.js.map +1 -0
  121. package/dist/esm/ChildProcessSpawner.d.ts +100 -0
  122. package/dist/esm/ChildProcessSpawner.d.ts.map +1 -0
  123. package/dist/esm/ChildProcessSpawner.js +342 -0
  124. package/dist/esm/ChildProcessSpawner.js.map +1 -0
  125. package/dist/esm/CommandLine.d.ts +173 -0
  126. package/dist/esm/CommandLine.d.ts.map +1 -0
  127. package/dist/esm/CommandLine.js +195 -0
  128. package/dist/esm/CommandLine.js.map +1 -0
  129. package/dist/esm/ContainedSpawner.d.ts +135 -0
  130. package/dist/esm/ContainedSpawner.d.ts.map +1 -0
  131. package/dist/esm/ContainedSpawner.js +180 -0
  132. package/dist/esm/ContainedSpawner.js.map +1 -0
  133. package/dist/esm/FileSystem.d.ts +452 -0
  134. package/dist/esm/FileSystem.d.ts.map +1 -0
  135. package/dist/esm/FileSystem.js +989 -0
  136. package/dist/esm/FileSystem.js.map +1 -0
  137. package/dist/esm/FileSystemBatch.d.ts +94 -0
  138. package/dist/esm/FileSystemBatch.d.ts.map +1 -0
  139. package/dist/esm/FileSystemBatch.js +26 -0
  140. package/dist/esm/FileSystemBatch.js.map +1 -0
  141. package/dist/esm/GrantEvent.d.ts +240 -0
  142. package/dist/esm/GrantEvent.d.ts.map +1 -0
  143. package/dist/esm/GrantEvent.js +155 -0
  144. package/dist/esm/GrantEvent.js.map +1 -0
  145. package/dist/esm/GrantStore.d.ts +270 -0
  146. package/dist/esm/GrantStore.d.ts.map +1 -0
  147. package/dist/esm/GrantStore.js +951 -0
  148. package/dist/esm/GrantStore.js.map +1 -0
  149. package/dist/esm/HostError.d.ts +36 -0
  150. package/dist/esm/HostError.d.ts.map +1 -0
  151. package/dist/esm/HostError.js +20 -0
  152. package/dist/esm/HostError.js.map +1 -0
  153. package/dist/esm/HostServices.d.ts +101 -0
  154. package/dist/esm/HostServices.d.ts.map +1 -0
  155. package/dist/esm/HostServices.js +76 -0
  156. package/dist/esm/HostServices.js.map +1 -0
  157. package/dist/esm/HttpClient.d.ts +177 -0
  158. package/dist/esm/HttpClient.d.ts.map +1 -0
  159. package/dist/esm/HttpClient.js +299 -0
  160. package/dist/esm/HttpClient.js.map +1 -0
  161. package/dist/esm/Jj.d.ts +67 -0
  162. package/dist/esm/Jj.d.ts.map +1 -0
  163. package/dist/esm/Jj.js +111 -0
  164. package/dist/esm/Jj.js.map +1 -0
  165. package/dist/esm/JournalGrantStore.d.ts +65 -0
  166. package/dist/esm/JournalGrantStore.d.ts.map +1 -0
  167. package/dist/esm/JournalGrantStore.js +350 -0
  168. package/dist/esm/JournalGrantStore.js.map +1 -0
  169. package/dist/esm/Path.d.ts +37 -0
  170. package/dist/esm/Path.d.ts.map +1 -0
  171. package/dist/esm/Path.js +34 -0
  172. package/dist/esm/Path.js.map +1 -0
  173. package/dist/esm/ProcessConfinement.d.ts +55 -0
  174. package/dist/esm/ProcessConfinement.d.ts.map +1 -0
  175. package/dist/esm/ProcessConfinement.js +106 -0
  176. package/dist/esm/ProcessConfinement.js.map +1 -0
  177. package/dist/esm/ProcessLedger.d.ts +260 -0
  178. package/dist/esm/ProcessLedger.d.ts.map +1 -0
  179. package/dist/esm/ProcessLedger.js +283 -0
  180. package/dist/esm/ProcessLedger.js.map +1 -0
  181. package/dist/esm/Rooted.d.ts +53 -0
  182. package/dist/esm/Rooted.d.ts.map +1 -0
  183. package/dist/esm/Rooted.js +104 -0
  184. package/dist/esm/Rooted.js.map +1 -0
  185. package/dist/esm/Unreachable.d.ts +27 -0
  186. package/dist/esm/Unreachable.d.ts.map +1 -0
  187. package/dist/esm/Unreachable.js +26 -0
  188. package/dist/esm/Unreachable.js.map +1 -0
  189. package/dist/esm/Workspace.d.ts +62 -0
  190. package/dist/esm/Workspace.d.ts.map +1 -0
  191. package/dist/esm/Workspace.js +51 -0
  192. package/dist/esm/Workspace.js.map +1 -0
  193. package/dist/esm/index.d.ts +161 -0
  194. package/dist/esm/index.d.ts.map +1 -0
  195. package/dist/esm/index.js +151 -0
  196. package/dist/esm/index.js.map +1 -0
  197. package/dist/esm/internal/Containment.d.ts +24 -0
  198. package/dist/esm/internal/Containment.d.ts.map +1 -0
  199. package/dist/esm/internal/Containment.js +27 -0
  200. package/dist/esm/internal/Containment.js.map +1 -0
  201. package/dist/esm/internal/Pipeline.d.ts +18 -0
  202. package/dist/esm/internal/Pipeline.d.ts.map +1 -0
  203. package/dist/esm/internal/Pipeline.js +68 -0
  204. package/dist/esm/internal/Pipeline.js.map +1 -0
  205. package/dist/esm/internal/makeCapability.d.ts +16 -0
  206. package/dist/esm/internal/makeCapability.d.ts.map +1 -0
  207. package/dist/esm/internal/makeCapability.js +22 -0
  208. package/dist/esm/internal/makeCapability.js.map +1 -0
  209. package/dist/esm/test/FileSystemBatchContract.d.ts +15 -0
  210. package/dist/esm/test/FileSystemBatchContract.d.ts.map +1 -0
  211. package/dist/esm/test/FileSystemBatchContract.js +57 -0
  212. package/dist/esm/test/FileSystemBatchContract.js.map +1 -0
  213. package/dist/esm/test/HostContract.d.ts +225 -0
  214. package/dist/esm/test/HostContract.d.ts.map +1 -0
  215. package/dist/esm/test/HostContract.js +460 -0
  216. package/dist/esm/test/HostContract.js.map +1 -0
  217. package/dist/esm/test/TestGrantStore.d.ts +31 -0
  218. package/dist/esm/test/TestGrantStore.d.ts.map +1 -0
  219. package/dist/esm/test/TestGrantStore.js +57 -0
  220. package/dist/esm/test/TestGrantStore.js.map +1 -0
  221. package/docs/README.md +162 -0
  222. package/docs/api.md +1532 -0
  223. package/docs/concepts/decoration-in-place.md +117 -0
  224. package/docs/concepts/filesystem-batches.md +58 -0
  225. package/docs/concepts/filesystem-confinement.md +168 -0
  226. package/docs/concepts/grant-decisions.md +181 -0
  227. package/docs/concepts/process-containment.md +155 -0
  228. package/docs/contract.md +14 -0
  229. package/docs/guides/adapt-a-new-host-platform.md +148 -0
  230. package/docs/guides/answer-permission-requests.md +151 -0
  231. package/docs/guides/authorize-network-and-model-calls.md +118 -0
  232. package/docs/guides/contain-spawned-processes.md +118 -0
  233. package/docs/guides/guard-a-host-bundle.md +109 -0
  234. package/docs/guides/persist-grants-across-restarts.md +135 -0
  235. package/docs/guides/write-a-capability-policy.md +159 -0
  236. package/docs/installation.md +115 -0
  237. package/docs/quickstart.md +182 -0
  238. package/docs/testing.md +108 -0
  239. package/docs/troubleshooting.md +281 -0
  240. package/package.json +344 -3
  241. package/src/CapabilitySet.ts +6 -0
  242. package/src/ChildProcessEnvironment.ts +98 -0
  243. package/src/ChildProcessSpawner.ts +414 -0
  244. package/src/CommandLine.ts +250 -0
  245. package/src/ContainedSpawner.ts +284 -0
  246. package/src/FileSystem.ts +1655 -0
  247. package/src/FileSystemBatch.ts +90 -0
  248. package/src/GrantEvent.ts +174 -0
  249. package/src/GrantStore.ts +1345 -0
  250. package/src/HostError.ts +37 -0
  251. package/src/HostServices.ts +133 -0
  252. package/src/HttpClient.ts +406 -0
  253. package/src/Jj.ts +159 -0
  254. package/src/JournalGrantStore.ts +461 -0
  255. package/src/Path.ts +47 -0
  256. package/src/ProcessConfinement.ts +156 -0
  257. package/src/ProcessLedger.ts +423 -0
  258. package/src/Rooted.ts +128 -0
  259. package/src/Unreachable.ts +30 -0
  260. package/src/Workspace.ts +65 -0
  261. package/src/index.ts +181 -0
  262. package/src/internal/Containment.ts +37 -0
  263. package/src/internal/Pipeline.ts +85 -0
  264. package/src/internal/makeCapability.ts +26 -0
  265. package/src/test/FileSystemBatchContract.ts +59 -0
  266. package/src/test/HostContract.ts +785 -0
  267. package/src/test/TestGrantStore.ts +73 -0
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @since 1.0.0-rc.0
3
+ *
4
+ * Host domain errors, shaped after `effect/PlatformError`.
5
+ *
6
+ * Effect's platform errors carry normalized operation data — a stable `_tag`
7
+ * reason, the `module` and `method` that failed, a human `description`, and the
8
+ * original `cause` — and are constructed through small helpers rather than
9
+ * `new`. We mirror that exactly, with two deliberate deviations:
10
+ *
11
+ * - the normalized reason lives in a `code` field rather than a nested
12
+ * `reason._tag`, because ours is a small closed set per service; and
13
+ * - the classes are `Schema.TaggedError`, not `Data.TaggedError`, because
14
+ * Host failures cross the durability boundary and must round-trip.
15
+ *
16
+ * Codes are a STABLE public contract: callers branch on them, step keys digest
17
+ * them, UIs map them to remediation. Never repurpose a code — add one.
18
+ */
19
+
20
+ import type { JjError } from "@smthrs/jj"
21
+
22
+ /**
23
+ * Every failure the Host surface is allowed to surface.
24
+ *
25
+ * `JjError` is declared by `@smthrs/jj` beside its contract; it is named here
26
+ * because that service is still part of the closed Host surface
27
+ * (`HostServices.ts`). This is a type union, not a re-export — import the
28
+ * error itself from its package.
29
+ *
30
+ * Running a command is absent on purpose: process execution is Effect's
31
+ * `ChildProcessSpawner`, so it fails with `PlatformError` like the rest of
32
+ * `effect`'s platform surface.
33
+ *
34
+ * @category models
35
+ * @since 1.0.0-rc.0
36
+ */
37
+ export type HostError = JjError
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The closed Host service list.
3
+ *
4
+ * One list, one set of tags. The kernel decorates each slot **in place** — a
5
+ * middleware `Layer` over the very tag the platform adapter provides — so
6
+ * there is no second, "protected" tag to keep in slot-order sync with this
7
+ * one, and no cast to force a guarded implementation onto a narrower error
8
+ * contract.
9
+ *
10
+ * Governing design:
11
+ * `docs/specs/Concepts/Permission Kernel.md`,
12
+ * `docs/specs/Concepts/Trust Granularity.md`, and
13
+ * `docs/specs/Concepts/Host Adapters.md`.
14
+ *
15
+ * @since 1.0.0-rc.0
16
+ */
17
+
18
+ import { Jj as JjPort } from "@smthrs/jj"
19
+ import { FileSystem as EffectFileSystem, Layer, Path as EffectPath } from "effect"
20
+ import { HttpClient as HttpClientPort } from "effect/unstable/http/HttpClient"
21
+ import { ChildProcessSpawner as ChildProcessSpawnerPort } from "effect/unstable/process/ChildProcessSpawner"
22
+ import * as ChildProcessSpawner from "./ChildProcessSpawner.ts"
23
+ import * as FileSystem from "./FileSystem.ts"
24
+ import * as HttpClient from "./HttpClient.ts"
25
+ import * as Jj from "./Jj.ts"
26
+ import * as Path from "./Path.ts"
27
+
28
+ /**
29
+ * The CLOSED LIST of platform ports the kernel protects.
30
+ * Optional filesystem batches belong to the existing FileSystem slot. The
31
+ * decorator grants each member separately and exposes no additional tag or
32
+ * unguarded executor to consumers; host slot identities remain unchanged.
33
+ *
34
+ * Everything that touches the outside world enters Smithers through exactly one
35
+ * of these tags — there is no ambient `node:fs`, no bare `spawn`, no global
36
+ * `Date.now()`. Two things depend on that closure:
37
+ *
38
+ * 1. The capability kernel wraps precisely this list. A service that is not
39
+ * here cannot be attenuated, denied, or audited, so it must not exist.
40
+ * 2. Step keys digest this list as their `layers` component. Which platform
41
+ * implementations were in scope is part of a step's identity, so a replay
42
+ * under different layers is a different step rather than a silent lie.
43
+ *
44
+ * Each slot's guarded implementation replaces the raw one under the same tag,
45
+ * so a consumer that never heard of the kernel still cannot bypass it. Where
46
+ * Effect owns the tag (`FileSystem`, `Path`, `ChildProcessSpawner`,
47
+ * `HttpClient`) the error channel stays Effect's own — `PlatformError`, or
48
+ * `HttpClientError` for the network — and permission failures are projected
49
+ * into it by `Permission.toPlatformError` and `HttpClient.toHttpClientError`;
50
+ * where Smithers owns the service (`Jj`) the interface names the kernel's
51
+ * failures directly.
52
+ *
53
+ * `Path` is intentionally retained as an explicit decision: it checks nothing
54
+ * and resolves relative paths against `Workspace.root`.
55
+ * Network access is Effect's own `HttpClient`: there is no Smithers transport
56
+ * port beneath it, because a raw port would be a second way to reach the
57
+ * network whose contract never mentions permission.
58
+ *
59
+ * `Clock` and `Random` are Effect core built-ins: already port-shaped, already
60
+ * swappable via `Effect.provideService`, and never decorated here — a `Clock`
61
+ * or a `Random` carries no host authority to guard, so they are not ours to
62
+ * define. `ChildProcessSpawner` is the same story one layer out: process spawning is
63
+ * `effect/unstable/process`, and the slot holds Effect's tag rather than a
64
+ * Smithers wrapper around it.
65
+ *
66
+ * @category models
67
+ * @since 1.0.0-rc.0
68
+ */
69
+ export type HostService =
70
+ | EffectFileSystem.FileSystem
71
+ | EffectPath.Path
72
+ | ChildProcessSpawnerPort
73
+ | JjPort
74
+ | HttpClientPort
75
+
76
+ /**
77
+ * The Host tags consumed by the kernel at runtime, decorated in place by
78
+ * {@link layer}, and digested by the step-key planner.
79
+ *
80
+ * @category models
81
+ * @since 1.0.0-rc.0
82
+ */
83
+ export const HostServiceTags = [
84
+ EffectFileSystem.FileSystem,
85
+ EffectPath.Path,
86
+ ChildProcessSpawnerPort,
87
+ JjPort,
88
+ HttpClientPort
89
+ ] as const
90
+
91
+ /**
92
+ * Stable slot identifiers shared by host composition, kernel attenuation, and
93
+ * step-layer resolution.
94
+ *
95
+ * Each id is the tag key of its slot's service — Effect's own key for the
96
+ * services Effect ships, the defining module's path for ours. The ids are
97
+ * digested into step keys, so a rename invalidates every cached step naming
98
+ * the slot; rename only when the service itself changes, as when `Shell` was
99
+ * replaced by Effect's `ChildProcessSpawner`.
100
+ *
101
+ * @category models
102
+ * @since 1.0.0-rc.0
103
+ */
104
+ export const HostServiceIds = [
105
+ "effect/FileSystem",
106
+ "effect/Path",
107
+ "effect/process/ChildProcessSpawner",
108
+ "@smthrs/jj/Jj",
109
+ "effect/HttpClient"
110
+ ] as const
111
+
112
+ /**
113
+ * Builds the full protected Host surface over a host platform bundle and a
114
+ * `GrantStore`. `Workspace` remains a requirement so the exact same root
115
+ * service can be supplied to both the grant store and filesystem decorators.
116
+ *
117
+ * Every member both requires and provides its own slot's tag: provide this
118
+ * layer over a raw platform bundle and the guarded implementation shadows the
119
+ * raw one for everything downstream. There is no exception and no second tag
120
+ * list — `HttpClient` decorates Effect's network tag exactly the way
121
+ * `FileSystem` decorates Effect's filesystem tag. The host supplies
122
+ * `ProcessConfinement` for approved processes; absence refuses spawning.
123
+ *
124
+ * @category layers
125
+ * @since 1.0.0-rc.0
126
+ */
127
+ export const layer = Layer.mergeAll(
128
+ FileSystem.layer,
129
+ Path.layer,
130
+ ChildProcessSpawner.layer,
131
+ Jj.layer,
132
+ HttpClient.layer
133
+ )
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Permission-aware outgoing HTTP requests.
3
+ *
4
+ * There is no kernel `HttpClient` interface and no kernel `HttpClient` tag.
5
+ * Effect owns both, and its tag fixes the error channel to `HttpClientError`,
6
+ * so this module is only the middleware: a `Layer` over Effect's own tag that
7
+ * reads the raw client out of context and returns a guarded one in its place.
8
+ * Permission failures are projected into `HttpClientError` by
9
+ * {@link toHttpClientError}, which keeps the structured kernel failure on the
10
+ * transport reason's `cause` so {@link fromHttpClientError} can hand it back.
11
+ *
12
+ * **Redirects.** A redirect is a second network destination, so it needs a
13
+ * second grant check. Two halves guarantee that:
14
+ *
15
+ * 1. The client below this layer must **not** follow redirects on its own.
16
+ * That is a precondition on the host: Undici without a redirect
17
+ * interceptor, or Effect's fetch layer with `redirect: "manual"`. This
18
+ * layer also forces `RequestInit { redirect: "manual" }` over the ambient
19
+ * value for every request it executes, so a plain `FetchHttpClient.layer`,
20
+ * which follows redirects by default, cannot walk to another origin below
21
+ * the guard either. A custom client that ignores `RequestInit` must meet
22
+ * the precondition itself.
23
+ * 2. This layer composes Effect's own `HttpClient.followRedirects` *above*
24
+ * the guard. That combinator re-enters `postprocess` for every hop, and
25
+ * `postprocess` is the guarded one, so hop *n* is checked exactly like hop
26
+ * zero and an authorized origin cannot lend its grant to another.
27
+ * A caller that sets `FetchHttpClient.RequestInit.redirect` to `manual`
28
+ * receives one guarded hop and owns any further redirect requests itself.
29
+ *
30
+ * Governing design:
31
+ * `docs/specs/Concepts/Permission Kernel.md`,
32
+ * `docs/specs/Concepts/Effect Taxonomy.md`, and
33
+ * `docs/specs/Concepts/Host Adapters.md`.
34
+ *
35
+ * @since 1.0.0-rc.0
36
+ */
37
+
38
+ import type { Capability } from "@smthrs/capability/Capability"
39
+ import {
40
+ formatError,
41
+ isPermissionError,
42
+ PermissionDenied,
43
+ type PermissionError,
44
+ type PermissionErrorPayload
45
+ } from "@smthrs/capability/Permission"
46
+ import { Context, Effect, Layer, Option } from "effect"
47
+ import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"
48
+ import * as Headers from "effect/unstable/http/Headers"
49
+ import * as HttpBody from "effect/unstable/http/HttpBody"
50
+ import * as EffectHttpClient from "effect/unstable/http/HttpClient"
51
+ import * as HttpClientError from "effect/unstable/http/HttpClientError"
52
+ import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
53
+ import * as UrlParams from "effect/unstable/http/UrlParams"
54
+ import { GrantStore, type Service as GrantService } from "./GrantStore.ts"
55
+ import { makeCapability } from "./internal/makeCapability.ts"
56
+
57
+ const snapshotRawBody = (value: unknown): unknown => {
58
+ if (value instanceof globalThis.Uint8Array) return value.slice()
59
+ if (value instanceof ArrayBuffer) return value.slice(0)
60
+ if (value instanceof URLSearchParams) {
61
+ return new URLSearchParams(value)
62
+ }
63
+ if (value instanceof Blob) return value
64
+ if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
65
+ return value
66
+ }
67
+ throw new TypeError("raw HTTP bodies must be immutable or copyable")
68
+ }
69
+
70
+ const snapshotBody = (body: HttpBody.HttpBody): HttpBody.HttpBody => {
71
+ switch (body._tag) {
72
+ case "Empty":
73
+ return HttpBody.empty
74
+ case "Raw":
75
+ return HttpBody.raw(snapshotRawBody(body.body), {
76
+ contentType: body.contentType,
77
+ contentLength: body.contentLength
78
+ })
79
+ case "Uint8Array":
80
+ return HttpBody.uint8Array(body.body.slice(), body.contentType)
81
+ case "FormData": {
82
+ const formData = new globalThis.FormData()
83
+ for (const [name, value] of body.formData.entries()) formData.append(name, value)
84
+ return HttpBody.formData(formData)
85
+ }
86
+ case "Stream":
87
+ return HttpBody.stream(body.stream, body.contentType, body.contentLength)
88
+ }
89
+ }
90
+
91
+ const snapshotRequest = (request: HttpClientRequest.HttpClientRequest): HttpClientRequest.HttpClientRequest =>
92
+ HttpClientRequest.makeWith(
93
+ request.method,
94
+ request.url,
95
+ UrlParams.make(
96
+ Object.freeze(request.urlParams.params.map(([name, value]) => Object.freeze([name, value] as const)))
97
+ ),
98
+ Option.map(request.hash, (value) => value),
99
+ Headers.fromInput(request.headers),
100
+ snapshotBody(request.body)
101
+ )
102
+
103
+ const invalidRequest = (request: HttpClientRequest.HttpClientRequest): HttpClientError.HttpClientError =>
104
+ new HttpClientError.HttpClientError({
105
+ reason: new HttpClientError.TransportError({
106
+ request,
107
+ description: "HTTP request must be an immutable supported request description"
108
+ })
109
+ })
110
+
111
+ /**
112
+ * Effect's own outgoing HTTP client tag, unchanged. Re-exported so the kernel
113
+ * namespace stays one-stop; it is the *same* tag, never a second one.
114
+ *
115
+ * @category services
116
+ * @since 1.0.0-rc.0
117
+ */
118
+ export { HttpClient } from "effect/unstable/http/HttpClient"
119
+
120
+ /**
121
+ * Constructs a client from a low-level request runner, keeping Effect's own
122
+ * URL construction, tracing, header redaction, and interruption behavior.
123
+ *
124
+ * @category constructors
125
+ * @since 1.0.0-rc.0
126
+ */
127
+ export { make } from "effect/unstable/http/HttpClient"
128
+
129
+ /**
130
+ * The model identity an outgoing request is made on behalf of, if any.
131
+ *
132
+ * A model call is not a general `net:*` effect: the same host answers many
133
+ * models, and a grant for one must not be a grant for the rest. Effect's tag
134
+ * has no room for an extra method, so the intent rides on a `Context`
135
+ * reference instead — set it with {@link withModelCall} and the decorator asks
136
+ * for `model:call` rather than `net:get`/`net:post`.
137
+ *
138
+ * @category references
139
+ * @since 1.0.0-rc.0
140
+ */
141
+ export const ModelCall: Context.Reference<string | undefined> = Context.Reference<string | undefined>(
142
+ "@smthrs/kernel/HttpClient/ModelCall",
143
+ { defaultValue: (): string | undefined => undefined }
144
+ )
145
+
146
+ /**
147
+ * Runs `effect` with every outgoing request in it checked as a `model:call` on
148
+ * `modelId` instead of as plain network access.
149
+ *
150
+ * @category references
151
+ * @since 1.0.0-rc.0
152
+ */
153
+ export const withModelCall = (modelId: string) => <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
154
+ Effect.provideService(effect, ModelCall, modelId)
155
+
156
+ /**
157
+ * Projects a permission failure into the error channel Effect's `HttpClient`
158
+ * tag fixes.
159
+ *
160
+ * Nothing is lost: the reason is always a `TransportError` — the request did
161
+ * not leave the host because the capability kernel refused, suspended, or
162
+ * could not decide it — `description` carries the human rendering from
163
+ * `Permission.formatError`, and `cause` carries the structured failure itself,
164
+ * so {@link fromHttpClientError} hands the attended surface back the original
165
+ * `capability`, `tier`, `requestId`, and `reason`.
166
+ *
167
+ * @category constructors
168
+ * @since 1.0.0-rc.0
169
+ */
170
+ export const toHttpClientError = (options: {
171
+ readonly request: HttpClientRequest.HttpClientRequest
172
+ readonly error: PermissionError
173
+ }): HttpClientError.HttpClientError =>
174
+ new HttpClientError.HttpClientError({
175
+ reason: new HttpClientError.TransportError({
176
+ request: options.request,
177
+ description: formatError(options.error),
178
+ cause: options.error
179
+ })
180
+ })
181
+
182
+ /**
183
+ * Recovers the structured permission failure a {@link toHttpClientError}
184
+ * projection carries, so an attended surface can still reply to the request
185
+ * and an unattended report can still name the capability.
186
+ *
187
+ * Recovery validates data fields and the transport reason tag, not origin or
188
+ * class operations. Establish producer or request identity separately across
189
+ * a trust boundary.
190
+ *
191
+ * @category refinements
192
+ * @since 1.0.0-rc.0
193
+ */
194
+ export const fromHttpClientError = (
195
+ error: HttpClientError.HttpClientError
196
+ ): Option.Option<PermissionErrorPayload> =>
197
+ error.reason._tag === "TransportError" && isPermissionError(error.reason.cause)
198
+ ? Option.some(error.reason.cause)
199
+ : Option.none()
200
+
201
+ /**
202
+ * Constructs an unavailable HTTP client stub.
203
+ *
204
+ * Every request reports the missing host as a `TransportError` naming the
205
+ * request, so an unconfigured capability answers rather than vanishing.
206
+ *
207
+ * @category constructors
208
+ * @since 1.0.0-rc.0
209
+ */
210
+ export const makeNoop = (): EffectHttpClient.HttpClient =>
211
+ EffectHttpClient.make((request) =>
212
+ Effect.fail(
213
+ new HttpClientError.HttpClientError({
214
+ reason: new HttpClientError.TransportError({
215
+ request,
216
+ cause: new Error("HTTP is unavailable on this host")
217
+ })
218
+ })
219
+ )
220
+ )
221
+
222
+ /**
223
+ * Provides an unavailable HTTP client stub.
224
+ *
225
+ * @category layers
226
+ * @since 1.0.0-rc.0
227
+ */
228
+ export const layerNoop = (): Layer.Layer<EffectHttpClient.HttpClient> =>
229
+ Layer.succeed(EffectHttpClient.HttpClient)(makeNoop())
230
+
231
+ /**
232
+ * Keeps HTTPS resources host-only so existing grants remain valid. Every
233
+ * other scheme is explicit, which prevents an HTTPS grant from authorizing a
234
+ * cleartext or otherwise different transport.
235
+ */
236
+ const capabilityFor = (
237
+ request: HttpClientRequest.HttpClientRequest,
238
+ modelId: string | undefined
239
+ ): Effect.Effect<Capability, PermissionError> => {
240
+ const action = modelId === undefined
241
+ ? request.method === "GET" || request.method === "HEAD" ? "net:get" : "net:post"
242
+ : "model:call"
243
+ let url: URL
244
+ try {
245
+ url = new URL(request.url)
246
+ } catch {
247
+ return makeCapability(action, request.url).pipe(
248
+ Effect.flatMap((capability) =>
249
+ Effect.fail(
250
+ new PermissionDenied({
251
+ code: "permission_denied",
252
+ capability,
253
+ reason: "HTTP capability checks require an absolute, parseable URL"
254
+ })
255
+ )
256
+ )
257
+ )
258
+ }
259
+ const host = url.host.toLowerCase()
260
+ const resource = url.protocol === "https:" ? host : `${url.protocol}//${host}`
261
+ return makeCapability(action, modelId === undefined ? resource : `${resource}/${modelId}`)
262
+ }
263
+
264
+ interface PreflightAdmission {
265
+ readonly store: GrantService
266
+ readonly capability: Capability
267
+ consumed: boolean
268
+ }
269
+
270
+ const Admission = Context.Reference<PreflightAdmission | undefined>("@smthrs/kernel/HttpClient/Admission", {
271
+ defaultValue: () => undefined
272
+ })
273
+
274
+ /**
275
+ * Authorizes a request before a caller performs network preflight work such as
276
+ * DNS. The guarded transport consumes the same admission once, so a one-shot
277
+ * grant is not spent twice. Admissions match both store identity and the exact
278
+ * capability; redirects and retries require their own authorization.
279
+ *
280
+ * @category authorization
281
+ * @since 1.0.0
282
+ */
283
+ export const authorizePreflight = <A, E, R>(
284
+ request: HttpClientRequest.HttpClientRequest,
285
+ effect: Effect.Effect<A, E, R>
286
+ ): Effect.Effect<A, E | HttpClientError.HttpClientError, R | GrantStore> =>
287
+ Effect.gen(function*() {
288
+ const store = yield* GrantStore
289
+ const capability = yield* Effect.withFiber((fiber) => capabilityFor(request, fiber.getRef(ModelCall))).pipe(
290
+ Effect.tap((capability) => store.check(capability)),
291
+ Effect.mapError((error) => toHttpClientError({ request, error }))
292
+ )
293
+ return yield* Effect.provideService(effect, Admission, { store, capability, consumed: false })
294
+ })
295
+
296
+ /**
297
+ * Forces `redirect: "manual"` on the fetch options a fetch-backed client reads
298
+ * at execute time, keeping every other ambient field.
299
+ */
300
+ const manualRedirects = (context: Context.Context<never>): Context.Context<never> =>
301
+ Context.add(context, FetchHttpClient.RequestInit, {
302
+ ...Context.getOrUndefined(context, FetchHttpClient.RequestInit),
303
+ redirect: "manual"
304
+ })
305
+
306
+ /**
307
+ * Decorates the HTTP client in place with a `net:get`/`net:post`/`model:call`
308
+ * capability check, then follows redirects *through* that check.
309
+ *
310
+ * The layer provides the tag it also requires: compose it over a host client
311
+ * layer with `Layer.provide` and a consumer that never heard of the kernel is
312
+ * guarded too. Because every hop re-enters the guarded `postprocess`, an
313
+ * authorized first URL that redirects to an unauthorized one is denied at the
314
+ * second hop instead of being fetched.
315
+ *
316
+ * @category layers
317
+ * @since 1.0.0-rc.0
318
+ */
319
+ export const layer: Layer.Layer<
320
+ EffectHttpClient.HttpClient,
321
+ never,
322
+ EffectHttpClient.HttpClient | GrantStore
323
+ > = Layer.effect(
324
+ EffectHttpClient.HttpClient,
325
+ Effect.gen(function*() {
326
+ const client = yield* EffectHttpClient.HttpClient
327
+ const grants = yield* GrantStore
328
+ const snapshotted = EffectHttpClient.mapRequestEffect(
329
+ client,
330
+ (request) =>
331
+ Effect.try({
332
+ try: () => snapshotRequest(request),
333
+ catch: () => invalidRequest(request)
334
+ })
335
+ )
336
+ const guarded = EffectHttpClient.transform(
337
+ snapshotted,
338
+ (execute, request) =>
339
+ Effect.withFiber((fiber) =>
340
+ capabilityFor(request, fiber.getRef(ModelCall)).pipe(
341
+ Effect.flatMap((capability) => {
342
+ const admission = fiber.getRef(Admission)
343
+ if (
344
+ admission !== undefined && !admission.consumed && admission.store === grants
345
+ && admission.capability.action === capability.action &&
346
+ admission.capability.resource === capability.resource
347
+ ) {
348
+ admission.consumed = true
349
+ return Effect.void
350
+ }
351
+ return grants.check(capability)
352
+ }),
353
+ Effect.mapError((error: PermissionError) => toHttpClientError({ request, error })),
354
+ Effect.andThen(Effect.updateContext(execute, manualRedirects))
355
+ )
356
+ )
357
+ )
358
+ const followed = EffectHttpClient.followRedirects(guarded)
359
+ const result = EffectHttpClient.makeWith(
360
+ (request) =>
361
+ Effect.withFiber((fiber) =>
362
+ // Read caller policy before the guard forces manual transport.
363
+ Context.getOrUndefined(fiber.context, FetchHttpClient.RequestInit)?.redirect === "manual"
364
+ ? guarded.postprocess(request)
365
+ : followed.postprocess(request)
366
+ ),
367
+ guarded.preprocess
368
+ )
369
+ return supportsDestinationPinning(client) ? withDestinationPinning(result) : result
370
+ })
371
+ )
372
+
373
+ /**
374
+ * Address snapshot authorized for one web request. Host transports must reject
375
+ * a different origin and use only these addresses for every connection attempt.
376
+ * Ordinary model traffic leaves this reference unset.
377
+ * @category references
378
+ * @since 1.0.0
379
+ */
380
+ export const Destination = Context.Reference<
381
+ {
382
+ readonly origin: string
383
+ readonly addresses: ReadonlyArray<string>
384
+ } | undefined
385
+ >("@smthrs/kernel/HttpClient/Destination", { defaultValue: () => undefined })
386
+
387
+ const pinnedClients = new WeakSet<EffectHttpClient.HttpClient>()
388
+
389
+ /**
390
+ * Marks a trusted transport that enforces Destination on every connection.
391
+ * The assertion belongs to this client instance, not ambient context. A
392
+ * replacement client must establish its own transport guarantee.
393
+ * @category constructors
394
+ * @since 1.0.0
395
+ */
396
+ export const withDestinationPinning = <A extends EffectHttpClient.HttpClient>(client: A): A => {
397
+ pinnedClients.add(client)
398
+ return client
399
+ }
400
+
401
+ /**
402
+ * Whether this exact client implements the Destination transport contract.
403
+ * @category predicates
404
+ * @since 1.0.0
405
+ */
406
+ export const supportsDestinationPinning = (client: EffectHttpClient.HttpClient): boolean => pinnedClients.has(client)