@joe-broadhead/messaging 0.0.0-stage → 0.7.0

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 (257) hide show
  1. package/CHANGELOG.md +175 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +12 -0
  4. package/README.md +36 -2
  5. package/dist/bin/messaging-mcp.js +25887 -0
  6. package/dist/bin/messaging-relay.js +19014 -0
  7. package/dist/bin/messaging.js +14732 -0
  8. package/dist/bin/messagingd.js +28682 -0
  9. package/dist/lib/core-testing.js +1 -0
  10. package/dist/lib/core.js +1 -0
  11. package/dist/lib/ipc-testing.js +1 -0
  12. package/dist/lib/ipc.js +1 -0
  13. package/dist/lib/modules/core/address.js +42 -0
  14. package/dist/lib/modules/core/agent/operations.js +595 -0
  15. package/dist/lib/modules/core/agent/present.js +244 -0
  16. package/dist/lib/modules/core/agent/text.js +79 -0
  17. package/dist/lib/modules/core/agent-tools.js +458 -0
  18. package/dist/lib/modules/core/b64u.js +20 -0
  19. package/dist/lib/modules/core/cards.js +55 -0
  20. package/dist/lib/modules/core/check.js +20 -0
  21. package/dist/lib/modules/core/consent.js +199 -0
  22. package/dist/lib/modules/core/contact-card.js +74 -0
  23. package/dist/lib/modules/core/delegation.js +174 -0
  24. package/dist/lib/modules/core/directory/device-log.js +258 -0
  25. package/dist/lib/modules/core/directory/epoch.js +103 -0
  26. package/dist/lib/modules/core/directory/handle.js +29 -0
  27. package/dist/lib/modules/core/directory/jelly-device.js +204 -0
  28. package/dist/lib/modules/core/directory/jelly-issuer.js +12 -0
  29. package/dist/lib/modules/core/directory/jelly-token.js +305 -0
  30. package/dist/lib/modules/core/directory/keys-statement.js +43 -0
  31. package/dist/lib/modules/core/directory/link.js +81 -0
  32. package/dist/lib/modules/core/directory/merkle-log.js +250 -0
  33. package/dist/lib/modules/core/directory/person-view.js +197 -0
  34. package/dist/lib/modules/core/directory/person.js +40 -0
  35. package/dist/lib/modules/core/directory/realm.js +59 -0
  36. package/dist/lib/modules/core/directory/smt.js +266 -0
  37. package/dist/lib/modules/core/envelope.js +320 -0
  38. package/dist/lib/modules/core/hash.js +5 -0
  39. package/dist/lib/modules/core/head.js +27 -0
  40. package/dist/lib/modules/core/identity.js +71 -0
  41. package/dist/lib/modules/core/index.js +44 -0
  42. package/dist/lib/modules/core/jcs.js +96 -0
  43. package/dist/lib/modules/core/jelly-host-double.js +35 -0
  44. package/dist/lib/modules/core/jelly-host.js +211 -0
  45. package/dist/lib/modules/core/keys.js +79 -0
  46. package/dist/lib/modules/core/peer-text.js +563 -0
  47. package/dist/lib/modules/core/realm.js +1 -0
  48. package/dist/lib/modules/core/roster.js +454 -0
  49. package/dist/lib/modules/core/schema/a2a.js +85 -0
  50. package/dist/lib/modules/core/schema/common.js +254 -0
  51. package/dist/lib/modules/core/schema/consent.js +120 -0
  52. package/dist/lib/modules/core/schema/delegation.js +58 -0
  53. package/dist/lib/modules/core/schema/directory-frames.js +270 -0
  54. package/dist/lib/modules/core/schema/directory.js +169 -0
  55. package/dist/lib/modules/core/schema/envelope.js +211 -0
  56. package/dist/lib/modules/core/schema/errors.js +65 -0
  57. package/dist/lib/modules/core/schema/index.js +15 -0
  58. package/dist/lib/modules/core/schema/ipc/consent.js +181 -0
  59. package/dist/lib/modules/core/schema/ipc/device.js +276 -0
  60. package/dist/lib/modules/core/schema/ipc/jelly.js +189 -0
  61. package/dist/lib/modules/core/schema/ipc/shapes.js +185 -0
  62. package/dist/lib/modules/core/schema/ipc/surfaces.js +223 -0
  63. package/dist/lib/modules/core/schema/ipc.js +451 -0
  64. package/dist/lib/modules/core/schema/jelly.js +122 -0
  65. package/dist/lib/modules/core/schema/realm.js +27 -0
  66. package/dist/lib/modules/core/schema/relay.js +112 -0
  67. package/dist/lib/modules/core/schema/roster.js +126 -0
  68. package/dist/lib/modules/core/sign.js +43 -0
  69. package/dist/lib/modules/core/task.js +37 -0
  70. package/dist/lib/modules/core/testing.js +20 -0
  71. package/dist/lib/modules/core/ulid.js +36 -0
  72. package/dist/lib/modules/core/version.js +6 -0
  73. package/dist/lib/modules/core/wire.js +255 -0
  74. package/dist/lib/modules/ipc/client.js +289 -0
  75. package/dist/lib/modules/ipc/framing.js +33 -0
  76. package/dist/lib/modules/ipc/index.js +6 -0
  77. package/dist/lib/modules/ipc/paths.js +102 -0
  78. package/dist/lib/modules/ipc/pipes.js +65 -0
  79. package/dist/lib/modules/ipc/rpc.js +8 -0
  80. package/dist/lib/modules/ipc/server.js +317 -0
  81. package/dist/lib/modules/ipc/testing.js +145 -0
  82. package/dist/lib/modules/ipc/trust.js +166 -0
  83. package/dist/lib/modules/ipc/windows-acl.js +275 -0
  84. package/dist/lib/modules/vendor/zod/index.js +7 -0
  85. package/dist/lib/modules/vendor/zod/v4/classic/checks.js +1 -0
  86. package/dist/lib/modules/vendor/zod/v4/classic/coerce.js +17 -0
  87. package/dist/lib/modules/vendor/zod/v4/classic/compat.js +31 -0
  88. package/dist/lib/modules/vendor/zod/v4/classic/deep-partial.js +13 -0
  89. package/dist/lib/modules/vendor/zod/v4/classic/errors.js +57 -0
  90. package/dist/lib/modules/vendor/zod/v4/classic/external.js +17 -0
  91. package/dist/lib/modules/vendor/zod/v4/classic/from-json-schema.js +881 -0
  92. package/dist/lib/modules/vendor/zod/v4/classic/in-out.js +36 -0
  93. package/dist/lib/modules/vendor/zod/v4/classic/iso.js +15 -0
  94. package/dist/lib/modules/vendor/zod/v4/classic/parse.js +16 -0
  95. package/dist/lib/modules/vendor/zod/v4/classic/schemas.js +1535 -0
  96. package/dist/lib/modules/vendor/zod/v4/core/api.js +1107 -0
  97. package/dist/lib/modules/vendor/zod/v4/core/checks.js +516 -0
  98. package/dist/lib/modules/vendor/zod/v4/core/compile.js +1866 -0
  99. package/dist/lib/modules/vendor/zod/v4/core/core.js +141 -0
  100. package/dist/lib/modules/vendor/zod/v4/core/doc.js +38 -0
  101. package/dist/lib/modules/vendor/zod/v4/core/errors.js +267 -0
  102. package/dist/lib/modules/vendor/zod/v4/core/index.js +19 -0
  103. package/dist/lib/modules/vendor/zod/v4/core/json-schema-generator.js +99 -0
  104. package/dist/lib/modules/vendor/zod/v4/core/json-schema-processors.js +843 -0
  105. package/dist/lib/modules/vendor/zod/v4/core/json-schema.js +1 -0
  106. package/dist/lib/modules/vendor/zod/v4/core/memoizer.js +311 -0
  107. package/dist/lib/modules/vendor/zod/v4/core/parse.js +175 -0
  108. package/dist/lib/modules/vendor/zod/v4/core/regexes.js +156 -0
  109. package/dist/lib/modules/vendor/zod/v4/core/registries.js +51 -0
  110. package/dist/lib/modules/vendor/zod/v4/core/schemas.js +2636 -0
  111. package/dist/lib/modules/vendor/zod/v4/core/to-json-schema.js +651 -0
  112. package/dist/lib/modules/vendor/zod/v4/core/util.js +873 -0
  113. package/dist/lib/modules/vendor/zod/v4/core/versions.js +5 -0
  114. package/dist/lib/modules/vendor/zod/v4/core/visit.js +169 -0
  115. package/dist/lib/modules/vendor/zod/v4/locales/ar.js +111 -0
  116. package/dist/lib/modules/vendor/zod/v4/locales/az.js +110 -0
  117. package/dist/lib/modules/vendor/zod/v4/locales/be.js +168 -0
  118. package/dist/lib/modules/vendor/zod/v4/locales/bg.js +125 -0
  119. package/dist/lib/modules/vendor/zod/v4/locales/bn.js +113 -0
  120. package/dist/lib/modules/vendor/zod/v4/locales/ca.js +112 -0
  121. package/dist/lib/modules/vendor/zod/v4/locales/ckb.js +133 -0
  122. package/dist/lib/modules/vendor/zod/v4/locales/cs.js +116 -0
  123. package/dist/lib/modules/vendor/zod/v4/locales/da.js +120 -0
  124. package/dist/lib/modules/vendor/zod/v4/locales/de.js +113 -0
  125. package/dist/lib/modules/vendor/zod/v4/locales/el.js +112 -0
  126. package/dist/lib/modules/vendor/zod/v4/locales/en.js +125 -0
  127. package/dist/lib/modules/vendor/zod/v4/locales/eo.js +114 -0
  128. package/dist/lib/modules/vendor/zod/v4/locales/es.js +136 -0
  129. package/dist/lib/modules/vendor/zod/v4/locales/fa.js +119 -0
  130. package/dist/lib/modules/vendor/zod/v4/locales/fi.js +117 -0
  131. package/dist/lib/modules/vendor/zod/v4/locales/fr-CA.js +112 -0
  132. package/dist/lib/modules/vendor/zod/v4/locales/fr.js +129 -0
  133. package/dist/lib/modules/vendor/zod/v4/locales/gu.js +113 -0
  134. package/dist/lib/modules/vendor/zod/v4/locales/he.js +221 -0
  135. package/dist/lib/modules/vendor/zod/v4/locales/hi.js +111 -0
  136. package/dist/lib/modules/vendor/zod/v4/locales/hr.js +126 -0
  137. package/dist/lib/modules/vendor/zod/v4/locales/hu.js +113 -0
  138. package/dist/lib/modules/vendor/zod/v4/locales/hy.js +158 -0
  139. package/dist/lib/modules/vendor/zod/v4/locales/id.js +111 -0
  140. package/dist/lib/modules/vendor/zod/v4/locales/index.js +63 -0
  141. package/dist/lib/modules/vendor/zod/v4/locales/is.js +114 -0
  142. package/dist/lib/modules/vendor/zod/v4/locales/it.js +113 -0
  143. package/dist/lib/modules/vendor/zod/v4/locales/ja.js +112 -0
  144. package/dist/lib/modules/vendor/zod/v4/locales/ka.js +117 -0
  145. package/dist/lib/modules/vendor/zod/v4/locales/kh.js +5 -0
  146. package/dist/lib/modules/vendor/zod/v4/locales/km.js +115 -0
  147. package/dist/lib/modules/vendor/zod/v4/locales/kn.js +116 -0
  148. package/dist/lib/modules/vendor/zod/v4/locales/ko.js +116 -0
  149. package/dist/lib/modules/vendor/zod/v4/locales/lt.js +207 -0
  150. package/dist/lib/modules/vendor/zod/v4/locales/mk.js +114 -0
  151. package/dist/lib/modules/vendor/zod/v4/locales/ms.js +112 -0
  152. package/dist/lib/modules/vendor/zod/v4/locales/ne.js +111 -0
  153. package/dist/lib/modules/vendor/zod/v4/locales/nl.js +115 -0
  154. package/dist/lib/modules/vendor/zod/v4/locales/nn.js +113 -0
  155. package/dist/lib/modules/vendor/zod/v4/locales/no.js +113 -0
  156. package/dist/lib/modules/vendor/zod/v4/locales/ota.js +114 -0
  157. package/dist/lib/modules/vendor/zod/v4/locales/pl.js +114 -0
  158. package/dist/lib/modules/vendor/zod/v4/locales/ps.js +119 -0
  159. package/dist/lib/modules/vendor/zod/v4/locales/pt-BR.js +143 -0
  160. package/dist/lib/modules/vendor/zod/v4/locales/pt.js +142 -0
  161. package/dist/lib/modules/vendor/zod/v4/locales/ro.js +122 -0
  162. package/dist/lib/modules/vendor/zod/v4/locales/ru.js +168 -0
  163. package/dist/lib/modules/vendor/zod/v4/locales/sk.js +116 -0
  164. package/dist/lib/modules/vendor/zod/v4/locales/sl.js +114 -0
  165. package/dist/lib/modules/vendor/zod/v4/locales/sv.js +115 -0
  166. package/dist/lib/modules/vendor/zod/v4/locales/ta.js +115 -0
  167. package/dist/lib/modules/vendor/zod/v4/locales/tg.js +117 -0
  168. package/dist/lib/modules/vendor/zod/v4/locales/th.js +115 -0
  169. package/dist/lib/modules/vendor/zod/v4/locales/tk.js +107 -0
  170. package/dist/lib/modules/vendor/zod/v4/locales/tr.js +110 -0
  171. package/dist/lib/modules/vendor/zod/v4/locales/ua.js +5 -0
  172. package/dist/lib/modules/vendor/zod/v4/locales/uk.js +113 -0
  173. package/dist/lib/modules/vendor/zod/v4/locales/ur.js +115 -0
  174. package/dist/lib/modules/vendor/zod/v4/locales/uz.js +113 -0
  175. package/dist/lib/modules/vendor/zod/v4/locales/vi.js +113 -0
  176. package/dist/lib/modules/vendor/zod/v4/locales/yo.js +112 -0
  177. package/dist/lib/modules/vendor/zod/v4/locales/zh-CN.js +114 -0
  178. package/dist/lib/modules/vendor/zod/v4/locales/zh-TW.js +112 -0
  179. package/dist/lib/testing-jelly-id.js +1219 -0
  180. package/dist/lib/zod.js +1 -0
  181. package/dist/spec/messaging-v1.md +3426 -0
  182. package/dist/third-party-notices.md +239 -0
  183. package/dist/third-party.json +88 -0
  184. package/dist/types/core/address.d.ts +16 -0
  185. package/dist/types/core/agent/operations.d.ts +129 -0
  186. package/dist/types/core/agent/present.d.ts +57 -0
  187. package/dist/types/core/agent/text.d.ts +62 -0
  188. package/dist/types/core/agent-tools.d.ts +948 -0
  189. package/dist/types/core/b64u.d.ts +6 -0
  190. package/dist/types/core/cards.d.ts +21 -0
  191. package/dist/types/core/check.d.ts +10 -0
  192. package/dist/types/core/consent.d.ts +132 -0
  193. package/dist/types/core/contact-card.d.ts +34 -0
  194. package/dist/types/core/delegation.d.ts +71 -0
  195. package/dist/types/core/directory/device-log.d.ts +119 -0
  196. package/dist/types/core/directory/epoch.d.ts +53 -0
  197. package/dist/types/core/directory/handle.d.ts +11 -0
  198. package/dist/types/core/directory/jelly-device.d.ts +88 -0
  199. package/dist/types/core/directory/jelly-issuer.d.ts +8 -0
  200. package/dist/types/core/directory/jelly-token.d.ts +116 -0
  201. package/dist/types/core/directory/keys-statement.d.ts +22 -0
  202. package/dist/types/core/directory/link.d.ts +36 -0
  203. package/dist/types/core/directory/merkle-log.d.ts +40 -0
  204. package/dist/types/core/directory/person-view.d.ts +90 -0
  205. package/dist/types/core/directory/person.d.ts +8 -0
  206. package/dist/types/core/directory/realm.d.ts +23 -0
  207. package/dist/types/core/directory/smt.d.ts +52 -0
  208. package/dist/types/core/envelope.d.ts +68 -0
  209. package/dist/types/core/hash.d.ts +2 -0
  210. package/dist/types/core/head.d.ts +34 -0
  211. package/dist/types/core/identity.d.ts +35 -0
  212. package/dist/types/core/index.d.ts +38 -0
  213. package/dist/types/core/jcs.d.ts +12 -0
  214. package/dist/types/core/jelly-host-double.d.ts +21 -0
  215. package/dist/types/core/jelly-host.d.ts +86 -0
  216. package/dist/types/core/keys.d.ts +16 -0
  217. package/dist/types/core/peer-text.d.ts +87 -0
  218. package/dist/types/core/realm.d.ts +36 -0
  219. package/dist/types/core/roster.d.ts +146 -0
  220. package/dist/types/core/schema/a2a.d.ts +251 -0
  221. package/dist/types/core/schema/common.d.ts +272 -0
  222. package/dist/types/core/schema/consent.d.ts +140 -0
  223. package/dist/types/core/schema/delegation.d.ts +58 -0
  224. package/dist/types/core/schema/directory-frames.d.ts +1301 -0
  225. package/dist/types/core/schema/directory.d.ts +375 -0
  226. package/dist/types/core/schema/envelope.d.ts +750 -0
  227. package/dist/types/core/schema/errors.d.ts +104 -0
  228. package/dist/types/core/schema/index.d.ts +15 -0
  229. package/dist/types/core/schema/ipc/consent.d.ts +555 -0
  230. package/dist/types/core/schema/ipc/device.d.ts +729 -0
  231. package/dist/types/core/schema/ipc/jelly.d.ts +501 -0
  232. package/dist/types/core/schema/ipc/shapes.d.ts +411 -0
  233. package/dist/types/core/schema/ipc/surfaces.d.ts +674 -0
  234. package/dist/types/core/schema/ipc.d.ts +3131 -0
  235. package/dist/types/core/schema/jelly.d.ts +160 -0
  236. package/dist/types/core/schema/realm.d.ts +38 -0
  237. package/dist/types/core/schema/relay.d.ts +1122 -0
  238. package/dist/types/core/schema/roster.d.ts +347 -0
  239. package/dist/types/core/sign.d.ts +13 -0
  240. package/dist/types/core/task.d.ts +6 -0
  241. package/dist/types/core/testing.d.ts +18 -0
  242. package/dist/types/core/ulid.d.ts +5 -0
  243. package/dist/types/core/version.d.ts +6 -0
  244. package/dist/types/core/wire.d.ts +14 -0
  245. package/dist/types/ipc/client.d.ts +85 -0
  246. package/dist/types/ipc/framing.d.ts +12 -0
  247. package/dist/types/ipc/index.d.ts +6 -0
  248. package/dist/types/ipc/paths.d.ts +39 -0
  249. package/dist/types/ipc/pipes.d.ts +16 -0
  250. package/dist/types/ipc/rpc.d.ts +13 -0
  251. package/dist/types/ipc/server.d.ts +94 -0
  252. package/dist/types/ipc/testing.d.ts +89 -0
  253. package/dist/types/ipc/trust.d.ts +49 -0
  254. package/dist/types/ipc/windows-acl.d.ts +56 -0
  255. package/dist/types/testing/jelly-id.d.ts +518 -0
  256. package/dist/types/zod.d.ts +1 -0
  257. package/package.json +77 -4
@@ -0,0 +1,1219 @@
1
+ /**
2
+ * A Jelly ID issuer double, for tests only: the issuing side of Jelly's resource-token contract v1.1
3
+ * (`jelly-resource-token-contract-v1.1.md` §3-§5, §9-§11; design `docs/design/people-and-consent.md` §2.2, §3.7,
4
+ * §3.10, §11), with Jelly's change of 2026-11-05: an unbindable `cnf_jwk` is
5
+ * `400 {error: "invalid_target", detail: "invalid_cnf_jwk"}`, and a plain `invalid_target` (no `detail`) still
6
+ * means "no such resource".
7
+ *
8
+ * The directory tests (`packages/relay/test/directory/`) and the end-to-end suites (`test/e2e/`) import it by
9
+ * relative path; other repositories' tests import `@joe-broadhead/messaging/testing/jelly-id`, which carries it as tsc
10
+ * builds it (`test/doubles/tsconfig.json`). Production code never imports it. It loads only Node built-ins
11
+ * (`node:crypto`, `node:http`).
12
+ *
13
+ * What it serves, in process (`mint`, `events`, `jwks`, …) and over HTTP once `JellyIdDouble.start()` listens:
14
+ *
15
+ * | Route | Contract |
16
+ * |---|---|
17
+ * | `GET /.well-known/oauth-authorization-server` | §2: `jelly_resources`, `jelly_account_events` |
18
+ * | `GET /.well-known/jwks.json` | §4-§5: the current public keys; a key leaves `jwksDropMs` after a rotation |
19
+ * | `GET /.well-known/jwks-history.json` | §11: every key ever published, with `nbf`, `retired_at`, `revoked_at` |
20
+ * | `POST /hosts/<hostId>/resource-tokens` | §3: the mint, with a host's RFC 7523 assertion and an optional `cnf_jwk` |
21
+ * | `POST /resource/accounts/events` | §10: the deletion feed, with a service's assertion (§9) and a cursor |
22
+ * | `POST /resource/device/code` | v1.2 §12.1: a device client asks for a code (only while a resource has `device_sign_in`) |
23
+ * | `POST /resource/device/token` | v1.2 §12.3: the device polls for its `src: "device"` token |
24
+ * | `POST /hosts/<hostId>/wake` | Jelly's A5 (hey-jelly#36, 7 Nov): a service wakes a sleeping host with its `jelly-wake+jwt`, once per `jti` |
25
+ *
26
+ * Device sign-in (§12) behaves as Jelly's contract v1.2 §12 is built in hey-jelly
27
+ * (`backend/control/src/broker/resource-device-routes.ts` and `protocol/contracts/src/jelly-id-device-sign-in.ts`, at
28
+ * the commit `jelly-device-sign-in.md` beside this file cites): the same answers, in the same order of checks, and
29
+ * never a `verification_uri_complete`. The consent page of §12.2 is in process: `consentPage(code, {account})` and
30
+ * `decide(code, {account}, "approve" | "deny")` (or `approve(code, account)`), as the person signed in at Jelly ID,
31
+ * having typed the code. `jelly-id-device.test.ts` checks the double against that excerpt's tables.
32
+ *
33
+ * Time comes from `options.now` (milliseconds), so a test can rotate a key, age a cursor past retention or let a
34
+ * token expire without waiting: `manualClock()` gives one.
35
+ *
36
+ * Test-only powers a real issuer does not have, for adversarial cases: `sign()` signs any header and claims with
37
+ * any key the double ever held (a "stolen" retired key), `mint()` takes `iat` and `kid` overrides, and `fail()`
38
+ * makes a route answer an error. `requests` records method, path and status of every HTTP request, never a body
39
+ * or a header (the bodies carry assertions and tokens).
40
+ */
41
+ import { createHash, createPublicKey, generateKeyPairSync, randomBytes, sign as edSign, verify as edVerify } from "node:crypto";
42
+ import { createServer } from "node:http";
43
+ // ---------------------------------------------------------------------------------------------
44
+ // constants of the contract
45
+ export const RESOURCE_TOKEN_TYP = "jelly-resource+jwt";
46
+ export const JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
47
+ /** §4: `exp = iat + 600`. */
48
+ export const TOKEN_TTL_S = 600;
49
+ /** §3: more than 120 tokens for one host in an hour is `429 slow_down`. */
50
+ export const MINTS_PER_HOUR = 120;
51
+ /** §9: a service assertion lives at most 300 s; times hold with 60 s of leeway. */
52
+ export const ASSERTION_MAX_S = 300;
53
+ export const LEEWAY_S = 60;
54
+ /** §10: an event is served once it is 60 s old; events and cursors are kept 90 days; at most 100 an answer. */
55
+ export const FEED_DELAY_MS = 60_000;
56
+ export const FEED_RETENTION_MS = 90 * 24 * 3_600_000;
57
+ export const FEED_PAGE = 100;
58
+ /** §11: `jwks.json` drops a key about ten minutes after a rotation. */
59
+ export const JWKS_DROP_MS = 10 * 60_000;
60
+ /** Jelly's contract v1.2 §12 (`jelly-device-sign-in.md` beside this file): a service's device signs in through the browser. */
61
+ export const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
62
+ /** §12.1, §12.6: a code lives 600 s; the device polls every 5 s, and each `slow_down` adds 5 s to the code's interval. */
63
+ export const DEVICE_CODE_TTL_S = 600;
64
+ export const DEVICE_INTERVAL_S = 5;
65
+ export const SLOW_DOWN_STEP_S = 5;
66
+ /** §12.6: the limits. Approvals are counted per account and resource (Jelly's `approve:<account>:<resource>` window). */
67
+ export const DEVICE_CODES_PER_ADDRESS_HOUR = 10;
68
+ export const DEVICE_CODES_WAITING_PER_CLIENT = 1_000;
69
+ export const DEVICE_APPROVALS_PER_ACCOUNT_RESOURCE_DAY = 5;
70
+ /** §12.2: the page and the decision need a Jelly ID sign-in of the last 15 minutes. */
71
+ export const RECENT_SIGN_IN_MS = 15 * 60_000;
72
+ export const LINK_ATTEMPTS = 10;
73
+ export const LINK_WINDOW_MS = 15 * 60_000;
74
+ /** §12.1: Jelly's pairing-code alphabet, ten symbols grouped 5-5. */
75
+ const USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
76
+ const USER_CODE = /^[A-HJ-NP-Z2-9]{10}$/;
77
+ const LABEL = /^[a-z0-9][a-z0-9-]{0,31}$/;
78
+ const DEVICE_KINDS = new Set(["laptop", "server", "other"]);
79
+ /** §9: a service's `jwks_uri` is believed for an hour, refetched for an unknown `kid` at most once a minute. */
80
+ export const SERVICE_JWKS_TTL_MS = 3_600_000;
81
+ export const SERVICE_JWKS_REFETCH_MS = 60_000;
82
+ const SUB = /^acc_[A-Za-z0-9._:-]{1,123}$/;
83
+ const HST = /^hst_[A-Za-z0-9._:-]{1,123}$/;
84
+ const NAME = /^[a-z][a-z0-9-]{0,31}$/;
85
+ const B64U = /^[A-Za-z0-9_-]*$/;
86
+ // ---------------------------------------------------------------------------------------------
87
+ // small helpers
88
+ export const b64u = (bytes) => Buffer.from(bytes).toString("base64url");
89
+ const fromB64u = (text) => Buffer.from(text, "base64url");
90
+ const rand = (bytes) => b64u(randomBytes(bytes));
91
+ const seconds = (ms) => Math.floor(ms / 1000);
92
+ export function manualClock(start = Date.UTC(2026, 10, 6, 9, 0, 0)) {
93
+ let now = start;
94
+ const clock = (() => now);
95
+ clock.advance = (ms) => (now += ms);
96
+ clock.set = (ms) => (now = ms);
97
+ return clock;
98
+ }
99
+ export function ed25519() {
100
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
101
+ const x = publicKey.export({ format: "jwk" }).x;
102
+ return { privateKey, publicKey, x };
103
+ }
104
+ /** The `cnf_jwk` for a device's raw public key (base64url), as the plugin passes it to `token(name, {cnf})`. */
105
+ export const cnfOf = (x) => ({ kty: "OKP", crv: "Ed25519", x });
106
+ /** RFC 7638 thumbprint of an OKP key. */
107
+ export function thumbprint(jwk) {
108
+ return b64u(createHash("sha256").update(`{"crv":"${jwk.crv}","kty":"${jwk.kty}","x":"${jwk.x}"}`).digest());
109
+ }
110
+ /**
111
+ * §12.3: a key's fingerprint as the consent page shows it: the first six bytes of its RFC 7638 thumbprint, in
112
+ * lower-case hex, in three groups of four (`3fa9-1c07-e2b4`).
113
+ */
114
+ export function keyFingerprint(x) {
115
+ const hex = Buffer.from(thumbprint(cnfOf(x)), "base64url").subarray(0, 6).toString("hex");
116
+ return `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}`;
117
+ }
118
+ /** A compact JWS signed with Ed25519 over `b64u(header).b64u(payload)`. */
119
+ export function signJwt(header, claims, key) {
120
+ const input = `${b64u(JSON.stringify(header))}.${b64u(JSON.stringify(claims))}`;
121
+ return `${input}.${b64u(edSign(null, Buffer.from(input), key))}`;
122
+ }
123
+ /** Splits a compact JWS. Returns undefined for anything that is not three base64url segments of JSON objects. */
124
+ export function decodeJwt(token) {
125
+ const parts = token.split(".");
126
+ if (parts.length !== 3 || !parts.every((p) => p.length > 0 && B64U.test(p)))
127
+ return undefined;
128
+ try {
129
+ const header = JSON.parse(fromB64u(parts[0]).toString("utf8"));
130
+ const claims = JSON.parse(fromB64u(parts[1]).toString("utf8"));
131
+ if (!isObject(header) || !isObject(claims))
132
+ return undefined;
133
+ return { header, claims, input: `${parts[0]}.${parts[1]}`, signature: fromB64u(parts[2]) };
134
+ }
135
+ catch {
136
+ return undefined;
137
+ }
138
+ }
139
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
140
+ function publicKeyOf(x) {
141
+ return createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x }, format: "jwk" });
142
+ }
143
+ // ---------------------------------------------------------------------------------------------
144
+ // Ed25519 point checks (§3: `x` decodes to a canonical point, on the curve, not of small order)
145
+ const P = 2n ** 255n - 19n;
146
+ const mod = (a) => ((a % P) + P) % P;
147
+ function pow(base, exp) {
148
+ let result = 1n;
149
+ let b = mod(base);
150
+ let e = exp;
151
+ while (e > 0n) {
152
+ if (e & 1n)
153
+ result = mod(result * b);
154
+ b = mod(b * b);
155
+ e >>= 1n;
156
+ }
157
+ return result;
158
+ }
159
+ const inv = (a) => pow(a, P - 2n);
160
+ const D = mod(-121665n * inv(121666n));
161
+ const SQRT_M1 = pow(2n, (P - 1n) / 4n);
162
+ /** Why 32 bytes are not a bindable Ed25519 public key, or undefined when they are. */
163
+ export function pointProblem(bytes) {
164
+ if (bytes.length !== 32)
165
+ return "not 32 bytes";
166
+ let y = 0n;
167
+ for (let i = 31; i >= 0; i--)
168
+ y = (y << 8n) | BigInt(bytes[i]);
169
+ const sign = y >> 255n;
170
+ y &= (1n << 255n) - 1n;
171
+ if (y >= P)
172
+ return "y is not canonical";
173
+ const y2 = mod(y * y);
174
+ const u = mod(y2 - 1n);
175
+ const v = mod(D * y2 + 1n);
176
+ let x = mod(u * pow(v, 3n) * pow(u * pow(v, 7n), (P - 5n) / 8n));
177
+ const vx2 = mod(v * x * x);
178
+ if (vx2 === u) {
179
+ // x is a root
180
+ }
181
+ else if (vx2 === mod(-u)) {
182
+ x = mod(x * SQRT_M1);
183
+ }
184
+ else {
185
+ return "not on the curve";
186
+ }
187
+ if (x === 0n && sign === 1n)
188
+ return "x = 0 written with its sign bit";
189
+ if ((x & 1n) !== sign)
190
+ x = P - x;
191
+ // [8]P by three doublings in extended coordinates (a = -1); small order when it is the identity.
192
+ let X = x;
193
+ let Y = y;
194
+ let Z = 1n;
195
+ for (let i = 0; i < 3; i++) {
196
+ const A = mod(X * X);
197
+ const B = mod(Y * Y);
198
+ const C = mod(2n * Z * Z);
199
+ const H = mod(A + B);
200
+ const E = mod(H - mod((X + Y) * (X + Y)));
201
+ const G = mod(A - B);
202
+ const F = mod(C + G);
203
+ X = mod(E * F);
204
+ Y = mod(G * H);
205
+ Z = mod(F * G);
206
+ }
207
+ if (X === 0n && Y === Z)
208
+ return "small order";
209
+ return undefined;
210
+ }
211
+ /** Why a `cnf_jwk` is not bindable (§3), or undefined when it is. Takes the form value (JSON text) or an object. */
212
+ export function cnfProblem(value) {
213
+ let jwk = value;
214
+ if (typeof value === "string") {
215
+ try {
216
+ jwk = JSON.parse(value);
217
+ }
218
+ catch {
219
+ return "not JSON";
220
+ }
221
+ }
222
+ if (!isObject(jwk))
223
+ return "not an object";
224
+ const keys = Object.keys(jwk).sort();
225
+ if (keys.join(",") !== "crv,kty,x")
226
+ return `members are ${keys.join(",") || "none"}, not exactly kty, crv, x`;
227
+ if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519")
228
+ return "not an OKP Ed25519 key";
229
+ if (typeof jwk.x !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(jwk.x))
230
+ return "x is not 43 base64url characters";
231
+ const last = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".indexOf(jwk.x[42]);
232
+ if ((last & 3) !== 0)
233
+ return "x has non-zero spare bits";
234
+ return pointProblem(fromB64u(jwk.x));
235
+ }
236
+ /** Jelly's A5: a `jelly-wake+jwt` lives at most seven days; a host is woken at most 6 times an hour and 48 a day. */
237
+ export const WAKE_TICKET_MAX_S = 7 * 86_400;
238
+ export const WAKES_PER_HOUR = 6;
239
+ export const WAKES_PER_DAY = 48;
240
+ const NO_STORE = { "cache-control": "no-store" };
241
+ export class JellyIdDouble {
242
+ issuer;
243
+ resources;
244
+ now;
245
+ keys = [];
246
+ accounts = new Map();
247
+ hosts = new Map();
248
+ events = [];
249
+ /** HTTP requests seen: method, path (no query) and status only. */
250
+ requests = [];
251
+ /** resource → sub → first mint (ms): the scope of the deletion feed (§3, §10). */
252
+ minted = new Map();
253
+ /** Every token the mint issued, for tests that must check a log never shows one. */
254
+ issued = [];
255
+ /** Hosts woken (A5): which, when (ms), and the ticket's `jti`. */
256
+ wakes = [];
257
+ #wakeJtis = new Set();
258
+ #mints = new Map();
259
+ #jtis = new Set();
260
+ #cursors = new Map();
261
+ #faults = new Map();
262
+ #serviceKeys = new Map();
263
+ /** §12: every code asked for, by `device_code`. */
264
+ deviceCodes = new Map();
265
+ /** §12.6: fixed windows, as Jelly's store counts them: codes asked per client address, approvals per account and resource. */
266
+ #windows = new Map();
267
+ /** §12.2: wrong codes typed per consent session (ms). */
268
+ #misses = new Map();
269
+ #options;
270
+ #seq = 0;
271
+ #server;
272
+ constructor(options = {}) {
273
+ this.issuer = options.issuer ?? "https://id.jelly.test";
274
+ this.resources = [...(options.resources ?? [])];
275
+ this.now = options.now ?? Date.now;
276
+ this.#options = {
277
+ mintsPerHour: options.mintsPerHour ?? MINTS_PER_HOUR,
278
+ feedPage: options.feedPage ?? FEED_PAGE,
279
+ feedDelayMs: options.feedDelayMs ?? FEED_DELAY_MS,
280
+ feedRetentionMs: options.feedRetentionMs ?? FEED_RETENTION_MS,
281
+ jwksDropMs: options.jwksDropMs ?? JWKS_DROP_MS,
282
+ deviceIntervalS: options.deviceIntervalS ?? DEVICE_INTERVAL_S,
283
+ deviceCodeTtlS: options.deviceCodeTtlS ?? DEVICE_CODE_TTL_S,
284
+ };
285
+ for (const entry of this.resources) {
286
+ if (!NAME.test(entry.name))
287
+ throw new Error(`resource name ${entry.name} does not match ${NAME}`);
288
+ if (entry.jwks && entry.jwks_uri)
289
+ throw new Error(`resource ${entry.name}: jwks and jwks_uri are exclusive`);
290
+ }
291
+ if (!options.noKeys)
292
+ this.addKey();
293
+ }
294
+ /** Listens on 127.0.0.1 (a free port unless given); the issuer is the origin unless `options.issuer` is set. */
295
+ static async start(options = {}, port = 0) {
296
+ const double = new JellyIdDouble(options);
297
+ const server = createServer((req, res) => void double.#handle(req, res));
298
+ await new Promise((resolve, reject) => {
299
+ server.once("error", reject);
300
+ server.listen(port, "127.0.0.1", () => resolve());
301
+ });
302
+ double.#server = server;
303
+ if (options.issuer === undefined)
304
+ double.issuer = double.origin;
305
+ return double;
306
+ }
307
+ get origin() {
308
+ const address = this.#server?.address();
309
+ if (!address)
310
+ throw new Error("the Jelly ID double is not listening");
311
+ return `http://127.0.0.1:${address.port}`;
312
+ }
313
+ get jwksUri() {
314
+ return `${this.issuer}/.well-known/jwks.json`;
315
+ }
316
+ get historyUri() {
317
+ return `${this.issuer}/.well-known/jwks-history.json`;
318
+ }
319
+ get eventsUri() {
320
+ return `${this.issuer}/resource/accounts/events`;
321
+ }
322
+ /** v1.2 §12: the routes of §12.1 and §12.3, and the consent page of §12.2. */
323
+ get deviceCodeUri() {
324
+ return `${this.issuer}/resource/device/code`;
325
+ }
326
+ get deviceTokenUri() {
327
+ return `${this.issuer}/resource/device/token`;
328
+ }
329
+ get linkUri() {
330
+ return `${this.issuer}/link`;
331
+ }
332
+ async close() {
333
+ const server = this.#server;
334
+ if (!server)
335
+ return;
336
+ this.#server = undefined;
337
+ server.closeAllConnections();
338
+ await new Promise((resolve) => server.close(() => resolve()));
339
+ }
340
+ // ---- keys (§4, §11) ----
341
+ /** The key that signs now: the newest one not dropped or revoked. */
342
+ get signer() {
343
+ return [...this.keys].reverse().find((k) => k.rotatedAt === undefined && k.droppedAt === undefined && k.revokedAt === undefined);
344
+ }
345
+ addKey(kid = `k-${seconds(this.now())}-${rand(3)}`) {
346
+ if (this.keys.some((k) => k.kid === kid))
347
+ throw new Error(`kid ${kid} exists`);
348
+ const key = { kid, key: ed25519(), nbf: seconds(this.now()) };
349
+ this.keys.push(key);
350
+ return key;
351
+ }
352
+ /** A new key takes over signing; the old one stays in `jwks.json` for `jwksDropMs`, then only in the history. */
353
+ rotate(kid) {
354
+ const old = this.signer;
355
+ const fresh = this.addKey(kid);
356
+ if (old)
357
+ old.rotatedAt = this.now();
358
+ return fresh;
359
+ }
360
+ /** Drops a key from `jwks.json` now: its `retired_at` is now. */
361
+ retire(kid) {
362
+ const key = this.#key(kid);
363
+ key.rotatedAt ??= this.now();
364
+ key.droppedAt = this.now();
365
+ }
366
+ /** An operator revokes a key (`JELLY_ID_REVOKED_KEYS`): `revoked_at` in the history, gone from `jwks.json`. */
367
+ revoke(kid, atSeconds = seconds(this.now())) {
368
+ const key = this.#key(kid);
369
+ key.revokedAt = atSeconds;
370
+ key.droppedAt ??= this.now();
371
+ }
372
+ #key(kid) {
373
+ const key = this.keys.find((k) => k.kid === kid);
374
+ if (!key)
375
+ throw new Error(`no key ${kid}`);
376
+ return key;
377
+ }
378
+ /** Ms when the key left (or leaves) `jwks.json`, if it does. */
379
+ #dropTime(key) {
380
+ if (key.droppedAt !== undefined)
381
+ return key.droppedAt;
382
+ if (key.rotatedAt !== undefined)
383
+ return key.rotatedAt + this.#options.jwksDropMs;
384
+ return undefined;
385
+ }
386
+ #publicJwk(key) {
387
+ return { kty: "OKP", crv: "Ed25519", x: key.key.x, kid: key.kid, alg: "EdDSA", use: "sig" };
388
+ }
389
+ /** `jwks.json`: the published keys, newest (the signer) first. Never a private member. */
390
+ jwks() {
391
+ const now = this.now();
392
+ return {
393
+ keys: [...this.keys]
394
+ .reverse()
395
+ .filter((k) => {
396
+ const drop = this.#dropTime(k);
397
+ return drop === undefined || now < drop;
398
+ })
399
+ .map((k) => this.#publicJwk(k)),
400
+ };
401
+ }
402
+ /** `jwks-history.json` (§11): every key ever published, oldest first; it never shrinks. */
403
+ history() {
404
+ const now = this.now();
405
+ return {
406
+ keys: this.keys.map((k) => {
407
+ const drop = this.#dropTime(k);
408
+ const retired = drop !== undefined && now >= drop ? seconds(drop) : null;
409
+ return { ...this.#publicJwk(k), nbf: k.nbf, retired_at: retired, ...(k.revokedAt !== undefined ? { revoked_at: k.revokedAt } : {}) };
410
+ }),
411
+ };
412
+ }
413
+ /**
414
+ * The RFC 8414 metadata. Like Jelly ID's in production, it names `device_authorization_endpoint`: the route that
415
+ * enrols *hosts* with a host's assertion (`/device/code`), which a service's device must not take for §12. The
416
+ * double does not serve it. §12's own two members appear only when a resource has `device_sign_in` (v1.2 §2).
417
+ */
418
+ discovery() {
419
+ const resources = this.resources.map(({ name, resource, url }) => ({ name, resource, url }));
420
+ const deviceSignIn = this.resources.some((r) => r.device_sign_in === true);
421
+ return {
422
+ issuer: this.issuer,
423
+ authorization_endpoint: `${this.issuer}/authorize`,
424
+ token_endpoint: `${this.issuer}/token`,
425
+ device_authorization_endpoint: `${this.issuer}/device/code`,
426
+ jwks_uri: this.jwksUri,
427
+ grant_types_supported: ["authorization_code", "refresh_token", DEVICE_GRANT],
428
+ ...(resources.length > 0 ? { jelly_resources: resources, jelly_account_events: this.eventsUri } : {}),
429
+ ...(deviceSignIn ? { jelly_resource_device_authorization_endpoint: this.deviceCodeUri, jelly_resource_device_token_endpoint: this.deviceTokenUri } : {}),
430
+ };
431
+ }
432
+ /** Test-only: signs anything with any key the double holds or held (a stolen key, a backdated token). */
433
+ sign(header, claims, kid = this.signer?.kid) {
434
+ if (kid === undefined)
435
+ throw new Error("no signing key");
436
+ return signJwt(header, claims, this.#key(kid).key.privateKey);
437
+ }
438
+ // ---- accounts and hosts ----
439
+ account(opts = {}) {
440
+ const sub = opts.sub ?? `acc_${rand(9)}`;
441
+ if (!SUB.test(sub))
442
+ throw new Error(`sub ${sub} does not match ${SUB}`);
443
+ const emailVerified = opts.email !== undefined && opts.emailVerified !== false;
444
+ const account = { sub, email: opts.email, emailVerified, name: opts.name, state: "active" };
445
+ this.accounts.set(sub, account);
446
+ return account;
447
+ }
448
+ /** Enrols a host owned by `account`, with its own Ed25519 key for its client assertions. */
449
+ host(account, id = `hst_${rand(9)}`) {
450
+ if (!HST.test(id))
451
+ throw new Error(`host id ${id} does not match ${HST}`);
452
+ const host = { id, account, key: ed25519(), removed: false };
453
+ this.hosts.set(id, host);
454
+ return host;
455
+ }
456
+ /** The host's RFC 7523 client assertion (`iss = sub = hostId`, `aud` = the issuer). */
457
+ hostAssertion(host, opts = {}) {
458
+ const iat = opts.iat ?? seconds(this.now());
459
+ return signJwt({ alg: "EdDSA", typ: "JWT", kid: host.id }, { iss: host.id, sub: host.id, aud: opts.aud ?? this.issuer, iat, exp: iat + (opts.ttl ?? 60), jti: opts.jti ?? rand(16) }, opts.key ?? host.key.privateKey);
460
+ }
461
+ /**
462
+ * The host's wake ticket (Jelly's A5): a `jelly-wake+jwt` signed with its enrolled key, `iss` and `sub` the host, `aud`
463
+ * its wake URL, `svc` the service, a fresh `jti`, valid `ttl` seconds (default a day; Jelly allows seven).
464
+ */
465
+ wakeTicket(host, opts = {}) {
466
+ const iat = opts.iat ?? seconds(this.now());
467
+ return signJwt({ alg: "EdDSA", typ: opts.typ ?? "jelly-wake+jwt", kid: host.id }, { iss: host.id, sub: host.id, aud: opts.aud ?? this.wakeUrl(host), svc: opts.svc ?? "messaging", iat, exp: iat + (opts.ttl ?? 86_400), jti: opts.jti ?? rand(16) }, opts.key ?? host.key.privateKey);
468
+ }
469
+ /** `<issuer>/hosts/<id>/wake`. */
470
+ wakeUrl(host) {
471
+ return `${this.issuer}/hosts/${typeof host === "string" ? host : host.id}/wake`;
472
+ }
473
+ /**
474
+ * `POST /hosts/<hostId>/wake` as Jelly ID answers it (A5): `202` wakes the host and spends the ticket's `jti`; `401`
475
+ * for a ticket that is not this host's, has expired or was spent; `404` for no such host; `429` with `Retry-After`
476
+ * past the host's budget (6 an hour, 48 a day); `503` (by `fail("wake")`) spends nothing.
477
+ */
478
+ wake(hostId, form) {
479
+ const fault = this.#fault("wake");
480
+ if (fault)
481
+ return fault;
482
+ const host = this.hosts.get(hostId);
483
+ if (!host || host.removed)
484
+ return this.#error(404, "not_found");
485
+ if (form.get("client_assertion_type") !== "urn:jelly:wake" || duplicated(form, "ticket"))
486
+ return this.#error(400, "invalid_request");
487
+ const decoded = decodeJwt(form.get("ticket") ?? "");
488
+ const now = seconds(this.now());
489
+ const claims = decoded?.claims;
490
+ const good = decoded !== undefined &&
491
+ decoded.header.alg === "EdDSA" &&
492
+ decoded.header.typ === "jelly-wake+jwt" &&
493
+ edVerify(null, Buffer.from(decoded.input), host.key.publicKey, decoded.signature) &&
494
+ claims.iss === host.id &&
495
+ claims.sub === host.id &&
496
+ claims.aud === this.wakeUrl(host) &&
497
+ typeof claims.iat === "number" &&
498
+ typeof claims.exp === "number" &&
499
+ claims.iat <= now + LEEWAY_S &&
500
+ claims.exp > now &&
501
+ claims.exp - claims.iat <= WAKE_TICKET_MAX_S &&
502
+ typeof claims.jti === "string" &&
503
+ !this.#wakeJtis.has(claims.jti);
504
+ if (!good)
505
+ return this.#error(401, "invalid_client");
506
+ const at = this.now();
507
+ const mine = this.wakes.filter((w) => w.host === host.id);
508
+ const hour = mine.filter((w) => w.at > at - 3_600_000);
509
+ const day = mine.filter((w) => w.at > at - 86_400_000);
510
+ if (hour.length >= WAKES_PER_HOUR || day.length >= WAKES_PER_DAY) {
511
+ const oldest = (hour.length >= WAKES_PER_HOUR ? hour[0].at + 3_600_000 : day[0].at + 86_400_000) - at;
512
+ return { status: 429, body: { error: "slow_down" }, headers: { ...NO_STORE, "retry-after": String(Math.max(1, Math.ceil(oldest / 1000))) } };
513
+ }
514
+ this.#wakeJtis.add(claims.jti);
515
+ this.wakes.push({ host: host.id, at, jti: claims.jti });
516
+ return { status: 202, body: {}, headers: { ...NO_STORE } };
517
+ }
518
+ disable(account) {
519
+ account.state = "disabled";
520
+ }
521
+ /** The person asks to delete: no more tokens (§10, "From the request on…"). No event yet. */
522
+ requestDeletion(account) {
523
+ account.state = "deleting";
524
+ }
525
+ /** The grace period ends: `account.deleted` for every service the account was minted a token for (§10). */
526
+ purge(account) {
527
+ account.state = "purged";
528
+ const written = [];
529
+ for (const [resource, subs] of this.minted)
530
+ if (subs.has(account.sub))
531
+ written.push(this.emit({ sub: account.sub, resource }));
532
+ return written;
533
+ }
534
+ /** Test-only: writes any event (an unknown kind, an unknown person) into one service's feed. */
535
+ emit({ sub, resource, event = "account.deleted", atMs = this.now() }) {
536
+ const written = { id: `evt_${randomBytes(16).toString("hex")}`, sub, event, at: new Date(atMs).toISOString(), resource, seq: ++this.#seq, atMs };
537
+ this.events.push(written);
538
+ return written;
539
+ }
540
+ // ---- service keys (§9) ----
541
+ /** A service key pair and its public JWK (with `kid`), for `ResourceEntry.jwks` and `serviceAssertion`. */
542
+ static serviceKey(kid = `svc-${rand(4)}`) {
543
+ const pair = ed25519();
544
+ return { kid, pair, jwk: { kty: "OKP", crv: "Ed25519", x: pair.x, kid } };
545
+ }
546
+ /** A service's RFC 7523 assertion (§9): `iss = sub = name`, `aud` = the issuer. */
547
+ serviceAssertion(name, key, opts = {}) {
548
+ const iat = opts.iat ?? seconds(this.now());
549
+ return signJwt({ alg: "EdDSA", typ: "JWT", kid: key.kid }, { iss: opts.iss ?? name, sub: opts.sub ?? name, aud: opts.aud ?? this.issuer, iat, exp: iat + (opts.ttl ?? 120), jti: opts.jti ?? rand(16) }, key.pair.privateKey);
550
+ }
551
+ // ---- faults ----
552
+ /** The next `times` requests to `route` answer `status` with `body` (default a `temporarily_unavailable`). */
553
+ fail(route, status = 503, body = { error: "temporarily_unavailable" }, times = 1) {
554
+ this.#faults.set(route, { status, body, times });
555
+ }
556
+ #fault(route) {
557
+ const fault = this.#faults.get(route);
558
+ if (!fault)
559
+ return undefined;
560
+ if (--fault.times <= 0)
561
+ this.#faults.delete(route);
562
+ return { status: fault.status, body: fault.body, headers: { ...NO_STORE } };
563
+ }
564
+ // ---- the mint (§3, §4) ----
565
+ /**
566
+ * The mint as the HTTP route answers it, with the host's assertion checked (§3). `mint()` skips the assertion;
567
+ * use this to test `invalid_client`.
568
+ */
569
+ mintWithAssertion(hostId, form) {
570
+ const fault = this.#fault("mint");
571
+ if (fault)
572
+ return fault;
573
+ const unavailable = this.#unavailable();
574
+ if (unavailable)
575
+ return unavailable;
576
+ const host = this.hosts.get(hostId);
577
+ if (!host || host.removed || !this.#assertion(form, (claims, decoded) => claims.iss === host.id && claims.sub === host.id && edVerify(null, Buffer.from(decoded.input), host.key.publicKey, decoded.signature), "host")) {
578
+ return this.#error(401, "invalid_client");
579
+ }
580
+ if (duplicated(form, "resource") || duplicated(form, "cnf_jwk"))
581
+ return this.#error(400, "invalid_request");
582
+ const cnf = form.get("cnf_jwk");
583
+ return this.#mint({ host, resource: form.get("resource") ?? "", ...(cnf !== null ? { cnf } : {}) });
584
+ }
585
+ /** Mints as an enrolled, owned host asked for it (the assertion is assumed good). */
586
+ mint(args) {
587
+ const fault = this.#fault("mint");
588
+ if (fault)
589
+ return fault;
590
+ const unavailable = this.#unavailable();
591
+ if (unavailable)
592
+ return unavailable;
593
+ const host = typeof args.host === "string" ? this.hosts.get(args.host) : args.host;
594
+ if (!host || host.removed)
595
+ return this.#error(401, "invalid_client");
596
+ return this.#mint({ ...args, host });
597
+ }
598
+ /** `mint()` that returns the token or throws with the error code (and detail). */
599
+ token(args) {
600
+ const answer = this.mint(args);
601
+ if (answer.status !== 200)
602
+ throw Object.assign(new Error(`mint: ${answer.status} ${String(answer.body.error)}`), { code: answer.body.error, detail: answer.body.detail, status: answer.status });
603
+ return answer.body.access_token;
604
+ }
605
+ #unavailable() {
606
+ return this.signer === undefined || this.resources.length === 0 ? this.#error(503, "temporarily_unavailable") : undefined;
607
+ }
608
+ #mint(args) {
609
+ const { host } = args;
610
+ const entry = this.resources.find((r) => r.resource === args.resource);
611
+ if (!entry)
612
+ return this.#error(400, "invalid_target");
613
+ let cnf;
614
+ if (args.cnf !== undefined) {
615
+ const text = typeof args.cnf === "string" ? args.cnf : JSON.stringify(args.cnf);
616
+ if (cnfProblem(text) !== undefined)
617
+ return this.#error(400, "invalid_target", { detail: "invalid_cnf_jwk" });
618
+ const parsed = JSON.parse(text);
619
+ cnf = { kty: parsed.kty, crv: parsed.crv, x: parsed.x };
620
+ }
621
+ const account = host.account;
622
+ if (account.state !== "active")
623
+ return this.#error(403, "access_denied");
624
+ const now = this.now();
625
+ const recent = (this.#mints.get(host.id) ?? []).filter((t) => t > now - 3_600_000);
626
+ if (recent.length >= this.#options.mintsPerHour) {
627
+ this.#mints.set(host.id, recent);
628
+ return this.#error(429, "slow_down");
629
+ }
630
+ recent.push(now);
631
+ this.#mints.set(host.id, recent);
632
+ const key = args.kid !== undefined ? this.#key(args.kid) : this.signer;
633
+ const iat = args.iat ?? seconds(now);
634
+ const claims = {
635
+ iss: this.issuer,
636
+ aud: entry.resource,
637
+ sub: account.sub,
638
+ ...(account.emailVerified && account.email !== undefined ? { email: account.email, email_verified: true } : {}),
639
+ ...(account.name !== undefined ? { name: account.name } : {}),
640
+ hst: host.id,
641
+ ...(cnf ? { cnf: { jwk: cnf } } : {}),
642
+ iat,
643
+ exp: iat + TOKEN_TTL_S,
644
+ jti: rand(16),
645
+ };
646
+ const token = signJwt({ alg: "EdDSA", typ: RESOURCE_TOKEN_TYP, kid: key.kid }, claims, key.key.privateKey);
647
+ this.issued.push(token);
648
+ const subs = this.minted.get(entry.resource) ?? new Map();
649
+ if (!subs.has(account.sub))
650
+ subs.set(account.sub, now);
651
+ this.minted.set(entry.resource, subs);
652
+ return { status: 200, body: { access_token: token, token_type: "Bearer", expires_in: TOKEN_TTL_S, resource: entry.resource }, headers: { ...NO_STORE } };
653
+ }
654
+ #error(status, error, extra = {}) {
655
+ return { status, body: { error, ...extra }, headers: { ...NO_STORE } };
656
+ }
657
+ /**
658
+ * Checks an RFC 7523 assertion in `form`: the assertion type, `alg: EdDSA`, `aud` = the issuer, `exp - iat` ≤
659
+ * 300, the times with 60 s of leeway, a `jti` of at most 128 characters never seen under `prefix`, and `accept`
660
+ * (the issuer-specific `iss`/`sub` and signature check). The `jti` is spent only when everything else holds.
661
+ */
662
+ #assertion(form, accept, prefix) {
663
+ if (duplicated(form, "client_assertion") || duplicated(form, "client_assertion_type"))
664
+ return false;
665
+ if (form.get("client_assertion_type") !== JWT_BEARER)
666
+ return false;
667
+ const decoded = decodeJwt(form.get("client_assertion") ?? "");
668
+ if (!decoded || decoded.header.alg !== "EdDSA")
669
+ return false;
670
+ const { claims } = decoded;
671
+ const now = seconds(this.now());
672
+ const { iat, exp, jti } = claims;
673
+ if (claims.aud !== this.issuer)
674
+ return false;
675
+ if (typeof iat !== "number" || typeof exp !== "number" || exp - iat > ASSERTION_MAX_S || exp - iat <= 0)
676
+ return false;
677
+ if (iat > now + LEEWAY_S || exp < now - LEEWAY_S)
678
+ return false;
679
+ if (typeof jti !== "string" || jti.length === 0 || jti.length > 128 || this.#jtis.has(`${prefix}:${jti}`))
680
+ return false;
681
+ let ok = false;
682
+ try {
683
+ ok = accept(claims, decoded);
684
+ }
685
+ catch {
686
+ ok = false;
687
+ }
688
+ if (ok)
689
+ this.#jtis.add(`${prefix}:${jti}`);
690
+ return ok;
691
+ }
692
+ // ---- the deletion feed (§9, §10) ----
693
+ async #serviceKeysOf(entry, kid) {
694
+ if (entry.jwks)
695
+ return entry.jwks.keys;
696
+ if (!entry.jwks_uri)
697
+ return [];
698
+ const now = this.now();
699
+ const cached = this.#serviceKeys.get(entry.name);
700
+ const fresh = cached && now - cached.fetchedAt < SERVICE_JWKS_TTL_MS;
701
+ const knows = cached?.keys.some((k) => k.kid === kid);
702
+ if (cached && fresh && (knows || now - cached.lastRefetch < SERVICE_JWKS_REFETCH_MS))
703
+ return cached.keys;
704
+ try {
705
+ const response = await fetch(entry.jwks_uri, { redirect: "error", signal: AbortSignal.timeout(5_000) });
706
+ if (!response.ok)
707
+ return cached?.keys ?? [];
708
+ const body = (await response.json());
709
+ const keys = Array.isArray(body.keys) ? body.keys : [];
710
+ this.#serviceKeys.set(entry.name, { keys, fetchedAt: now, lastRefetch: now });
711
+ return keys;
712
+ }
713
+ catch {
714
+ return cached?.keys ?? [];
715
+ }
716
+ }
717
+ /** The deletion feed as the HTTP route answers it (§10). */
718
+ async accountEvents(form) {
719
+ const fault = this.#fault("events");
720
+ if (fault)
721
+ return fault;
722
+ if (this.resources.length === 0)
723
+ return this.#error(503, "temporarily_unavailable");
724
+ const decoded = decodeJwt(form.get("client_assertion") ?? "");
725
+ const name = decoded?.claims.iss;
726
+ const entry = typeof name === "string" ? this.resources.find((r) => r.name === name) : undefined;
727
+ const kid = decoded?.header.kid;
728
+ const keys = entry && typeof kid === "string" ? await this.#serviceKeysOf(entry, kid) : [];
729
+ const proven = entry !== undefined && this.#assertion(form, (claims, d) => {
730
+ if (claims.iss !== entry.name || claims.sub !== entry.name)
731
+ return false;
732
+ const jwk = keys.find((k) => k.kid === d.header.kid);
733
+ return jwk !== undefined && jwk.kty === "OKP" && jwk.crv === "Ed25519" && edVerify(null, Buffer.from(d.input), publicKeyOf(jwk.x), d.signature);
734
+ }, "service");
735
+ if (!proven)
736
+ return this.#error(401, "invalid_client");
737
+ if (duplicated(form, "cursor"))
738
+ return this.#error(400, "invalid_request");
739
+ const now = this.now();
740
+ const retention = this.#options.feedRetentionMs;
741
+ const bound = now - this.#options.feedDelayMs;
742
+ let from = { seq: 0, bound: 0 };
743
+ const cursor = form.get("cursor");
744
+ if (cursor !== null) {
745
+ const known = this.#cursors.get(cursor);
746
+ if (!known)
747
+ return this.#error(400, "invalid_request");
748
+ if (known.bound < now - retention)
749
+ return this.#error(410, "cursor_expired");
750
+ from = known;
751
+ }
752
+ // Retention: events older than 90 days are gone.
753
+ for (let i = this.events.length - 1; i >= 0; i--)
754
+ if (this.events[i].atMs < now - retention)
755
+ this.events.splice(i, 1);
756
+ const eligible = this.events.filter((e) => e.resource === entry.resource && e.seq > from.seq && e.atMs <= bound);
757
+ const page = eligible.slice(0, this.#options.feedPage);
758
+ const more = eligible.length > page.length;
759
+ const last = page.at(-1);
760
+ const next = more ? { seq: last.seq, bound: last.atMs } : { seq: last?.seq ?? from.seq, bound };
761
+ const id = `cur_${rand(12)}`;
762
+ this.#cursors.set(id, next);
763
+ return {
764
+ status: 200,
765
+ body: { events: page.map(({ id: eventId, sub, event, at }) => ({ id: eventId, sub, event, at })), cursor: id, more },
766
+ headers: { ...NO_STORE },
767
+ };
768
+ }
769
+ // ---- signing a device in through the browser (Jelly's contract v1.2 §12, as built) ----
770
+ /** §12.1: the device client of `clientId`, when it is a resource name with device sign-in on. */
771
+ #deviceClient(clientId) {
772
+ return this.resources.find((r) => r.name === clientId && r.device_sign_in === true);
773
+ }
774
+ /** Keeps the times within `windowMs` of now in `map[key]`, and says whether `limit` of them are already there. */
775
+ #over(map, key, windowMs, limit) {
776
+ const now = this.now();
777
+ const recent = (map.get(key) ?? []).filter((t) => t > now - windowMs);
778
+ map.set(key, recent);
779
+ return recent.length >= limit;
780
+ }
781
+ /**
782
+ * Counts one use against the window `key`, if it has room (Jelly's `takeDeviceSignInWindow`): a window starts at its
783
+ * first use and lasts `windowMs`; a use over `limit` in it is refused and not counted.
784
+ */
785
+ #take(key, limit, windowMs) {
786
+ const now = this.now();
787
+ const window = this.#windows.get(key);
788
+ if (!window || window.until <= now) {
789
+ this.#windows.set(key, { used: 1, until: now + windowMs });
790
+ return true;
791
+ }
792
+ if (window.used >= limit)
793
+ return false;
794
+ window.used += 1;
795
+ return true;
796
+ }
797
+ #userCode() {
798
+ for (;;) {
799
+ const bytes = randomBytes(10);
800
+ const code = [...bytes].map((b) => USER_CODE_ALPHABET[b % 32]).join("");
801
+ if (![...this.deviceCodes.values()].some((c) => c.userCode === code))
802
+ return code;
803
+ }
804
+ }
805
+ /**
806
+ * `POST /resource/device/code` (§12.1) as Jelly's route answers it, in its order of checks: every field present
807
+ * (a repeated one is missing), the client, the resource, the key, the label and kind, then the per-address and
808
+ * per-client limits. `address` is the client's address, for the per-address limit. Never `verification_uri_complete`.
809
+ */
810
+ deviceAuthorize(form, address = "127.0.0.1") {
811
+ const fault = this.#fault("device-code");
812
+ if (fault)
813
+ return fault;
814
+ const unavailable = this.#unavailable();
815
+ if (unavailable)
816
+ return unavailable;
817
+ const [clientId, resource, cnfText, label, kind] = ["client_id", "resource", "cnf_jwk", "label", "kind"].map((name) => field(form, name));
818
+ if ([clientId, resource, cnfText, label, kind].some((value) => value === undefined))
819
+ return this.#error(400, "invalid_request");
820
+ const client = this.#deviceClient(clientId);
821
+ if (!client)
822
+ return this.#error(401, "invalid_client");
823
+ if (resource !== client.resource)
824
+ return this.#error(400, "invalid_target");
825
+ if (cnfProblem(cnfText) !== undefined)
826
+ return this.#error(400, "invalid_target", { detail: "invalid_cnf_jwk" });
827
+ if (!LABEL.test(label) || !DEVICE_KINDS.has(kind))
828
+ return this.#error(400, "invalid_request");
829
+ const parsed = JSON.parse(cnfText);
830
+ const now = this.now();
831
+ if (!this.#take(`code:${address}`, DEVICE_CODES_PER_ADDRESS_HOUR, 3_600_000))
832
+ return this.#error(429, "slow_down");
833
+ const waiting = [...this.deviceCodes.values()].filter((c) => c.clientId === client.name && c.decision === undefined && c.expiresAt > now);
834
+ if (waiting.length >= DEVICE_CODES_WAITING_PER_CLIENT)
835
+ return this.#error(429, "slow_down");
836
+ const deviceCode = rand(32);
837
+ const userCode = this.#userCode();
838
+ this.deviceCodes.set(deviceCode, {
839
+ deviceCode,
840
+ userCode,
841
+ clientId: client.name,
842
+ resource: client.resource,
843
+ cnf: { kty: parsed.kty, crv: parsed.crv, x: parsed.x },
844
+ label: label,
845
+ kind: kind,
846
+ address,
847
+ createdAt: now,
848
+ expiresAt: now + this.#options.deviceCodeTtlS * 1000,
849
+ interval: this.#options.deviceIntervalS,
850
+ consumed: false,
851
+ });
852
+ return {
853
+ status: 200,
854
+ body: {
855
+ device_code: deviceCode,
856
+ user_code: `${userCode.slice(0, 5)}-${userCode.slice(5)}`,
857
+ verification_uri: this.linkUri,
858
+ expires_in: this.#options.deviceCodeTtlS,
859
+ interval: this.#options.deviceIntervalS,
860
+ },
861
+ headers: { ...NO_STORE },
862
+ };
863
+ }
864
+ /** The waiting code a person typed (any grouping or case), or undefined. */
865
+ #pending(code) {
866
+ const normal = code.toUpperCase().replace(/[\s-]+/g, "");
867
+ if (!USER_CODE.test(normal))
868
+ return undefined;
869
+ const now = this.now();
870
+ return [...this.deviceCodes.values()].find((c) => c.userCode === normal && c.decision === undefined && c.expiresAt > now);
871
+ }
872
+ /** §12.5: the account must be active, and not disabled or marked for deletion. */
873
+ #live(account) {
874
+ return this.accounts.get(account.sub) === account && account.state === "active";
875
+ }
876
+ /**
877
+ * The consent page of §12.2 for the person signed in as `account` (in `session`, signed in at `signedInAt`, default
878
+ * now), who typed `code` (`from: "typed"`, the default) or arrived with it from `/link?code=` (`from: "link"`). Errors:
879
+ * `account_inactive` (the live check), `type_the_code` (a code from a link is asked for again: "A sign-in code for a
880
+ * device cannot come from a link"), `sign_in_again` (a sign-in older than 15 minutes), `too_many_codes` (10 wrong
881
+ * codes in 15 minutes), `code_invalid` (unknown, decided or expired: "That code didn't work").
882
+ */
883
+ consentPage(code, as) {
884
+ if (!this.#live(as.account))
885
+ return { status: 403, error: "account_inactive" };
886
+ if (as.from === "link")
887
+ return { status: 400, error: "type_the_code" };
888
+ if (as.signedInAt !== undefined && this.now() - as.signedInAt > RECENT_SIGN_IN_MS)
889
+ return { status: 401, error: "sign_in_again" };
890
+ const session = as.session ?? as.account.sub;
891
+ if (this.#over(this.#misses, session, LINK_WINDOW_MS, LINK_ATTEMPTS))
892
+ return { status: 429, error: "too_many_codes" };
893
+ const row = this.#pending(code);
894
+ if (!row) {
895
+ this.#misses.get(session).push(this.now());
896
+ return { status: 400, error: "code_invalid" };
897
+ }
898
+ const entry = this.resources.find((r) => r.name === row.clientId);
899
+ return {
900
+ status: 200,
901
+ page: {
902
+ service: { name: entry.name, url: entry.url },
903
+ device: { label: row.label, kind: row.kind },
904
+ fingerprint: keyFingerprint(row.cnf.x),
905
+ userCode: `${row.userCode.slice(0, 5)}-${row.userCode.slice(5)}`,
906
+ account: as.account.email ?? as.account.name ?? as.account.sub,
907
+ asked: { at: new Date(row.createdAt).toISOString(), address: row.address },
908
+ says: "Check that the device shows this same code and fingerprint. If you did not start this yourself, on that device, just now, choose Deny.",
909
+ },
910
+ };
911
+ }
912
+ /**
913
+ * The person's decision on the consent page (`POST /link/device`, §12.2): `approve` or `deny`, once per code. Errors
914
+ * as `consentPage`, and `too_many_sign_ins` when the account approved 5 sign-ins to this resource in the day's window
915
+ * (§12.6): nothing is decided then, and the code waits on until it expires.
916
+ */
917
+ decide(code, as, decision) {
918
+ const page = this.consentPage(code, as);
919
+ if (page.status !== 200)
920
+ return page;
921
+ const row = this.#pending(code);
922
+ if (decision === "deny") {
923
+ row.decision = { approved: false };
924
+ return { status: 200, decided: "denied" };
925
+ }
926
+ if (!this.#take(`approve:${as.account.sub}:${row.resource}`, DEVICE_APPROVALS_PER_ACCOUNT_RESOURCE_DAY, 24 * 3_600_000))
927
+ return { status: 429, error: "too_many_sign_ins" };
928
+ row.decision = { approved: true, account: as.account };
929
+ return { status: 200, decided: "approved" };
930
+ }
931
+ /** The person approves the code a device shows (test shorthand); throws on a refusal. */
932
+ approve(code, account) {
933
+ const done = this.decide(code, { account }, "approve");
934
+ if (done.status !== 200)
935
+ throw Object.assign(new Error(`approve: ${done.error}`), { code: done.error });
936
+ }
937
+ /**
938
+ * A whole sign-in through the browser in process (§12.1-§12.3): a code for `cnf` from `client` (the resource's
939
+ * name), approved by `account`, exchanged once. Returns the `src: "device"` token, or throws with the error code.
940
+ */
941
+ signInToken(args) {
942
+ const resource = this.resources.find((r) => r.name === (args.client ?? "messaging"));
943
+ if (!resource?.device_sign_in)
944
+ throw Object.assign(new Error("signInToken: the resource has no device_sign_in"), { code: "invalid_client" });
945
+ const code = this.deviceAuthorize(new URLSearchParams({ client_id: resource.name, resource: resource.resource, cnf_jwk: JSON.stringify(args.cnf), label: args.label ?? "device", kind: args.kind ?? "laptop" }));
946
+ const body = code.body;
947
+ if (code.status !== 200)
948
+ throw Object.assign(new Error(`signInToken: ${body.error}`), { code: body.error });
949
+ this.approve(body.user_code, args.account);
950
+ const answer = this.deviceToken(new URLSearchParams({ grant_type: DEVICE_GRANT, device_code: body.device_code, client_id: resource.name }));
951
+ const token = answer.body;
952
+ if (answer.status !== 200)
953
+ throw Object.assign(new Error(`signInToken: ${token.error}`), { code: token.error });
954
+ return token.access_token;
955
+ }
956
+ /**
957
+ * `POST /resource/device/token` (§12.3) as Jelly's route answers it, in its order of checks: the grant type, the
958
+ * client, the code (recording the poll: one sooner than the code's interval grows it by 5 s), the code's client,
959
+ * spent, denied, expired (decided or not), undecided (`slow_down` when early, else `authorization_pending`), then
960
+ * the approval is spent and the account checked live. A repeated field is a missing one.
961
+ */
962
+ deviceToken(form) {
963
+ const fault = this.#fault("device-token");
964
+ if (fault)
965
+ return fault;
966
+ const unavailable = this.#unavailable();
967
+ if (unavailable)
968
+ return unavailable;
969
+ if (field(form, "grant_type") !== DEVICE_GRANT)
970
+ return this.#error(400, "unsupported_grant_type");
971
+ const client = this.#deviceClient(field(form, "client_id"));
972
+ if (!client)
973
+ return this.#error(401, "invalid_client");
974
+ const row = this.deviceCodes.get(field(form, "device_code") ?? "");
975
+ if (!row)
976
+ return this.#error(400, "invalid_grant");
977
+ const now = this.now();
978
+ const early = row.lastPoll !== undefined && now - row.lastPoll < row.interval * 1000;
979
+ row.lastPoll = now;
980
+ if (early)
981
+ row.interval += SLOW_DOWN_STEP_S;
982
+ if (row.clientId !== client.name || row.resource !== client.resource)
983
+ return this.#error(400, "invalid_grant");
984
+ if (row.consumed)
985
+ return this.#error(400, "invalid_grant");
986
+ if (row.decision?.approved === false)
987
+ return this.#error(400, "access_denied");
988
+ if (row.expiresAt <= now)
989
+ return this.#error(400, "expired_token");
990
+ if (row.decision === undefined)
991
+ return this.#error(400, early ? "slow_down" : "authorization_pending");
992
+ // §12.5: spent before the live check, so an approval is used once whatever it answers.
993
+ row.consumed = true;
994
+ const account = row.decision.account;
995
+ if (!this.#live(account))
996
+ return this.#error(400, "access_denied");
997
+ const key = this.signer;
998
+ const iat = seconds(now);
999
+ const claims = {
1000
+ iss: this.issuer,
1001
+ aud: row.resource,
1002
+ sub: account.sub,
1003
+ ...(account.emailVerified && account.email !== undefined ? { email: account.email, email_verified: true } : {}),
1004
+ ...(account.name !== undefined ? { name: account.name } : {}),
1005
+ cnf: { jwk: row.cnf },
1006
+ src: "device",
1007
+ iat,
1008
+ exp: iat + TOKEN_TTL_S,
1009
+ jti: rand(16),
1010
+ };
1011
+ const token = signJwt({ alg: "EdDSA", typ: RESOURCE_TOKEN_TYP, kid: key.kid }, claims, key.key.privateKey);
1012
+ this.issued.push(token);
1013
+ const subs = this.minted.get(row.resource) ?? new Map();
1014
+ if (!subs.has(account.sub))
1015
+ subs.set(account.sub, now);
1016
+ this.minted.set(row.resource, subs);
1017
+ return { status: 200, body: { access_token: token, token_type: "Bearer", expires_in: TOKEN_TTL_S, resource: row.resource }, headers: { ...NO_STORE } };
1018
+ }
1019
+ /** The waiting or decided code whose `user_code` (as shown) is `shown`. */
1020
+ codeOf(shown) {
1021
+ const normal = shown.toUpperCase().replace(/[\s-]+/g, "");
1022
+ return [...this.deviceCodes.values()].find((c) => c.userCode === normal);
1023
+ }
1024
+ // ---- HTTP ----
1025
+ async #handle(req, res) {
1026
+ const url = new URL(req.url ?? "/", "http://double");
1027
+ const path = url.pathname;
1028
+ let answer;
1029
+ try {
1030
+ answer = await this.#route(req, path);
1031
+ }
1032
+ catch {
1033
+ answer = this.#error(500, "server_error");
1034
+ }
1035
+ this.requests.push({ method: req.method ?? "", path, status: answer.status });
1036
+ res.writeHead(answer.status, { "content-type": "application/json", ...answer.headers });
1037
+ res.end(JSON.stringify(answer.body));
1038
+ }
1039
+ async #route(req, path) {
1040
+ const get = req.method === "GET";
1041
+ const post = req.method === "POST";
1042
+ const json = (body, cache) => ({ status: 200, body, headers: { "cache-control": cache } });
1043
+ if (get && path === "/.well-known/oauth-authorization-server")
1044
+ return this.#fault("discovery") ?? json(this.discovery(), "public, max-age=300");
1045
+ if (get && path === "/.well-known/jwks.json")
1046
+ return this.#fault("jwks") ?? json(this.jwks(), "public, max-age=600");
1047
+ if (get && path === "/.well-known/jwks-history.json")
1048
+ return this.#fault("history") ?? json(this.history(), "public, max-age=300");
1049
+ const mint = /^\/hosts\/([^/]+)\/resource-tokens$/.exec(path);
1050
+ if (post && mint) {
1051
+ const form = await readForm(req);
1052
+ if (!form)
1053
+ return this.#error(400, "invalid_request");
1054
+ return this.mintWithAssertion(decodeURIComponent(mint[1]), form);
1055
+ }
1056
+ const wake = /^\/hosts\/([^/]+)\/wake$/.exec(path);
1057
+ if (post && wake) {
1058
+ const form = await readForm(req);
1059
+ if (!form)
1060
+ return this.#error(400, "invalid_request");
1061
+ return this.wake(decodeURIComponent(wake[1]), form);
1062
+ }
1063
+ if (post && path === "/resource/accounts/events") {
1064
+ const form = await readForm(req);
1065
+ if (!form)
1066
+ return this.#error(400, "invalid_request");
1067
+ return this.accountEvents(form);
1068
+ }
1069
+ // §12's routes answer whatever the configuration, as Jelly's do: with no device client, a code is
1070
+ // `401 invalid_client`. Only discovery hides them. A body that is not a form has no fields.
1071
+ if (post && path === "/resource/device/code")
1072
+ return this.deviceAuthorize((await readForm(req)) ?? new URLSearchParams(), req.socket.remoteAddress ?? "unknown");
1073
+ if (post && path === "/resource/device/token")
1074
+ return this.deviceToken((await readForm(req)) ?? new URLSearchParams());
1075
+ return { status: 404, body: { error: "not_found" }, headers: {} };
1076
+ }
1077
+ }
1078
+ function duplicated(form, name) {
1079
+ return form.getAll(name).length > 1;
1080
+ }
1081
+ /** A form's field as Jelly's `formFields` reads it: a repeated field is a missing one. */
1082
+ function field(form, name) {
1083
+ const values = form.getAll(name);
1084
+ return values.length === 1 ? values[0] : undefined;
1085
+ }
1086
+ async function readForm(req) {
1087
+ if (!(req.headers["content-type"] ?? "").startsWith("application/x-www-form-urlencoded"))
1088
+ return undefined;
1089
+ const chunks = [];
1090
+ let size = 0;
1091
+ for await (const chunk of req) {
1092
+ size += chunk.length;
1093
+ if (size > 64 * 1024)
1094
+ return undefined;
1095
+ chunks.push(chunk);
1096
+ }
1097
+ return new URLSearchParams(Buffer.concat(chunks).toString("utf8"));
1098
+ }
1099
+ // ---------------------------------------------------------------------------------------------
1100
+ // HTTP clients of the double (what a host and a service send)
1101
+ /** `POST <issuer>/hosts/<hostId>/resource-tokens` as a host sends it (§3). */
1102
+ export async function requestToken(double, host, resource, cnf, assertion = double.hostAssertion(host)) {
1103
+ const form = new URLSearchParams({ client_assertion_type: JWT_BEARER, client_assertion: assertion, resource });
1104
+ if (cnf !== undefined)
1105
+ form.set("cnf_jwk", typeof cnf === "string" ? cnf : JSON.stringify(cnf));
1106
+ const response = await fetch(`${double.origin}/hosts/${encodeURIComponent(host.id)}/resource-tokens`, {
1107
+ method: "POST",
1108
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1109
+ body: form,
1110
+ });
1111
+ return { status: response.status, body: (await response.json()), cacheControl: response.headers.get("cache-control") };
1112
+ }
1113
+ /** `POST <issuer>/resource/accounts/events` as a service sends it (§10). */
1114
+ export async function requestEvents(double, assertion, cursor) {
1115
+ const form = new URLSearchParams({ client_assertion_type: JWT_BEARER, client_assertion: assertion });
1116
+ if (cursor !== undefined)
1117
+ form.set("cursor", cursor);
1118
+ const response = await fetch(`${double.origin}/resource/accounts/events`, {
1119
+ method: "POST",
1120
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1121
+ body: form,
1122
+ });
1123
+ return { status: response.status, body: (await response.json()), cacheControl: response.headers.get("cache-control") };
1124
+ }
1125
+ /** A form POST to the double, as a device client sends it (§12.2, §12.4). Each field is sent once unless an array. */
1126
+ async function postForm(url, fields) {
1127
+ const form = new URLSearchParams();
1128
+ for (const [name, value] of Object.entries(fields))
1129
+ for (const one of Array.isArray(value) ? value : [value])
1130
+ form.append(name, one);
1131
+ const response = await fetch(url, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: form });
1132
+ return { status: response.status, body: (await response.json()), cacheControl: response.headers.get("cache-control") };
1133
+ }
1134
+ /** `POST <issuer>/resource/device/code` (§12.2). */
1135
+ export function requestDeviceCode(double, fields) {
1136
+ return postForm(`${double.origin}/resource/device/code`, fields);
1137
+ }
1138
+ /** `POST <issuer>/resource/device/token` (§12.4). */
1139
+ export function requestDeviceToken(double, fields) {
1140
+ return postForm(`${double.origin}/resource/device/token`, fields);
1141
+ }
1142
+ /**
1143
+ * The accessor as Jelly's core plugin offers it on `host` (§6): tokens cached until 60 s before `exp`, single-flight
1144
+ * per resource and bound key (RFC 7638 thumbprint, or none), and a token bound to one key never handed out for
1145
+ * another key or for none. Rejections carry `code` (and, for an unbindable key, `detail: "invalid_cnf_jwk"`).
1146
+ * `install()` puts it on `globalThis[Symbol.for("jelly.identity")]` and returns the undo.
1147
+ */
1148
+ export function identityAccessor(double, host) {
1149
+ const cache = new Map();
1150
+ const flights = new Map();
1151
+ const reject = (code, detail) => {
1152
+ throw Object.assign(new Error(`jelly.identity: ${code}${detail ? ` (${detail})` : ""}`), { code, ...(detail ? { detail } : {}) });
1153
+ };
1154
+ const accessor = {
1155
+ mints: 0,
1156
+ resources: () => double.resources.map(({ name, resource, url }) => ({ name, resource, url })),
1157
+ async token(name, options) {
1158
+ if (!host)
1159
+ return reject("not_enrolled");
1160
+ if (options !== undefined) {
1161
+ const keys = Object.keys(options);
1162
+ if (keys.length !== 1 || keys[0] !== "cnf")
1163
+ return reject("invalid_request");
1164
+ const keysOfCnf = isObject(options.cnf) ? Object.keys(options.cnf).sort().join(",") : "";
1165
+ if (keysOfCnf !== "crv,kty,x")
1166
+ return reject("invalid_request");
1167
+ }
1168
+ const entry = double.resources.find((r) => r.name === name);
1169
+ if (!entry)
1170
+ return reject("invalid_target");
1171
+ const slot = `${entry.resource} ${options?.cnf ? thumbprint(options.cnf) : "-"}`;
1172
+ const cached = cache.get(slot);
1173
+ if (cached && double.now() < cached.expiresAt - 60_000)
1174
+ return cached;
1175
+ const flying = flights.get(slot);
1176
+ if (flying)
1177
+ return flying;
1178
+ const flight = (async () => {
1179
+ await Promise.resolve();
1180
+ accessor.mints++;
1181
+ const answer = double.mint({ host, resource: entry.resource, ...(options?.cnf ? { cnf: options.cnf } : {}) });
1182
+ if (answer.status !== 200) {
1183
+ const error = String(answer.body.error);
1184
+ if (error === "access_denied")
1185
+ return reject("access_denied");
1186
+ if (error === "invalid_target")
1187
+ return reject("invalid_target", answer.body.detail);
1188
+ if (error === "invalid_client")
1189
+ return reject("not_enrolled");
1190
+ return reject("unavailable");
1191
+ }
1192
+ const token = { accessToken: answer.body.access_token, expiresAt: double.now() + TOKEN_TTL_S * 1000 };
1193
+ cache.set(slot, token);
1194
+ return token;
1195
+ })();
1196
+ flights.set(slot, flight);
1197
+ try {
1198
+ return await flight;
1199
+ }
1200
+ finally {
1201
+ flights.delete(slot);
1202
+ }
1203
+ },
1204
+ install() {
1205
+ const symbol = Symbol.for("jelly.identity");
1206
+ const g = globalThis;
1207
+ const had = Object.hasOwn(g, symbol);
1208
+ const previous = g[symbol];
1209
+ g[symbol] = Object.freeze({ resources: accessor.resources, token: accessor.token });
1210
+ return () => {
1211
+ if (had)
1212
+ g[symbol] = previous;
1213
+ else
1214
+ delete g[symbol];
1215
+ };
1216
+ },
1217
+ };
1218
+ return accessor;
1219
+ }