@huynle/hub 0.7.3 → 0.8.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 (221) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +153 -24
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/profile.ts +173 -0
  9. package/packages/claude-code/src/sessions.ts +22 -4
  10. package/packages/cli/dist/hub-cli.js +6474 -3470
  11. package/packages/cli/package.json +1 -1
  12. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  13. package/packages/cli/src/artifacts-mcp.ts +160 -0
  14. package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
  15. package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
  16. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  17. package/packages/cli/src/cli.ts +95 -13
  18. package/packages/cli/src/format.ts +48 -10
  19. package/packages/cli/src/http.ts +1 -0
  20. package/packages/cli/src/intents.ts +23 -0
  21. package/packages/cli/src/login.ts +20 -6
  22. package/packages/cli/src/mcp.ts +380 -9
  23. package/packages/cli/src/messaging.ts +3 -1
  24. package/packages/cli/src/setup/commands.ts +3 -2
  25. package/packages/cli/src/setup/runtime.ts +3 -2
  26. package/packages/cli/src/team-intake-mcp.ts +158 -0
  27. package/packages/cli/src/teams-mcp.ts +403 -0
  28. package/packages/cli/src/watchlist.ts +135 -0
  29. package/packages/codex/package.json +1 -1
  30. package/packages/codex/src/profile.ts +163 -0
  31. package/packages/codex/src/sessions.ts +12 -0
  32. package/packages/codex/src/spoke.ts +19 -1
  33. package/packages/node/package.json +1 -1
  34. package/packages/node/src/config.ts +96 -2
  35. package/packages/node/src/opencode-warm.ts +325 -0
  36. package/packages/node/src/run.ts +27 -1
  37. package/packages/preset/package.json +1 -1
  38. package/packages/preset/src/profile.ts +34 -0
  39. package/packages/preset/src/spoke.ts +4 -0
  40. package/packages/protocol/package.json +1 -1
  41. package/packages/protocol/src/artifacts.ts +142 -0
  42. package/packages/protocol/src/concierge.ts +366 -0
  43. package/packages/protocol/src/contracts.ts +104 -0
  44. package/packages/protocol/src/graph.ts +70 -0
  45. package/packages/protocol/src/hub-workspace.ts +201 -0
  46. package/packages/protocol/src/index.ts +169 -6
  47. package/packages/protocol/src/launch.ts +22 -0
  48. package/packages/protocol/src/profiles.ts +301 -0
  49. package/packages/protocol/src/teams.ts +204 -0
  50. package/packages/runtime-openshell/package.json +1 -1
  51. package/packages/runtime-sdk/package.json +1 -1
  52. package/packages/sdk/package.json +1 -1
  53. package/packages/sdk/src/address.ts +50 -2
  54. package/packages/sdk/src/artifact-jobs.ts +194 -0
  55. package/packages/sdk/src/artifacts.ts +187 -0
  56. package/packages/sdk/src/client.ts +226 -5
  57. package/packages/sdk/src/concierge.ts +88 -0
  58. package/packages/sdk/src/errors.ts +3 -1
  59. package/packages/sdk/src/index.ts +5 -0
  60. package/packages/sdk/src/team-intake.ts +122 -0
  61. package/packages/sdk/src/teams.ts +346 -0
  62. package/packages/sdk/src/types.ts +548 -1
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/packages/spoke-sdk/src/index.ts +1 -0
  65. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  66. package/src/central/actions.ts +7 -0
  67. package/src/central/agent/acting-routes.ts +2 -1
  68. package/src/central/agent/acting-switch.ts +2 -1
  69. package/src/central/agent/acting.ts +215 -10
  70. package/src/central/agent/app-tokens.ts +3 -2
  71. package/src/central/agent/audit.ts +17 -0
  72. package/src/central/agent/body.ts +163 -0
  73. package/src/central/agent/decisions.ts +69 -2
  74. package/src/central/agent/delivery.ts +95 -15
  75. package/src/central/agent/directory-routes.ts +37 -7
  76. package/src/central/agent/directory.ts +70 -14
  77. package/src/central/agent/errors.ts +5 -0
  78. package/src/central/agent/events-routes.ts +21 -4
  79. package/src/central/agent/events.ts +11 -3
  80. package/src/central/agent/form-check.ts +85 -0
  81. package/src/central/agent/grant-requests.ts +2 -1
  82. package/src/central/agent/grant-routes.ts +4 -3
  83. package/src/central/agent/grants.ts +29 -2
  84. package/src/central/agent/idempotency.ts +133 -0
  85. package/src/central/agent/intent-routes.ts +116 -0
  86. package/src/central/agent/intents.ts +505 -0
  87. package/src/central/agent/interaction-graph-routes.ts +212 -0
  88. package/src/central/agent/interaction-graph.ts +865 -0
  89. package/src/central/agent/launch-engine.ts +16 -2
  90. package/src/central/agent/launch-routes.ts +6 -1
  91. package/src/central/agent/launch-spec.ts +14 -1
  92. package/src/central/agent/message-routes.ts +2 -1
  93. package/src/central/agent/messages.ts +27 -2
  94. package/src/central/agent/names.ts +12 -2
  95. package/src/central/agent/read-routes.ts +86 -19
  96. package/src/central/agent/routes.ts +176 -5
  97. package/src/central/agent/webhook-routes.ts +2 -1
  98. package/src/central/agent-data.ts +4 -0
  99. package/src/central/artifacts/access.ts +44 -0
  100. package/src/central/artifacts/blob-store.ts +113 -0
  101. package/src/central/artifacts/blob-support.ts +98 -0
  102. package/src/central/artifacts/events.ts +27 -0
  103. package/src/central/artifacts/host-caps.ts +162 -0
  104. package/src/central/artifacts/host.ts +315 -0
  105. package/src/central/artifacts/idempotency.ts +109 -0
  106. package/src/central/artifacts/jobs-routes.ts +773 -0
  107. package/src/central/artifacts/jobs.ts +344 -0
  108. package/src/central/artifacts/maintenance.ts +103 -0
  109. package/src/central/artifacts/routes.ts +360 -0
  110. package/src/central/artifacts/store.ts +776 -0
  111. package/src/central/artifacts/workspaces.ts +31 -0
  112. package/src/central/backend/file-blobs.ts +377 -0
  113. package/src/central/backend/index.ts +12 -3
  114. package/src/central/backend/memory-blobs.ts +116 -0
  115. package/src/central/backend/postgres-blobs.ts +513 -0
  116. package/src/central/backend/postgres-migrate.ts +33 -0
  117. package/src/central/backend/postgres-state.ts +19 -0
  118. package/src/central/backend/types.ts +8 -0
  119. package/src/central/concierge/activity.ts +142 -0
  120. package/src/central/concierge/arm.ts +63 -0
  121. package/src/central/concierge/board-render.ts +190 -0
  122. package/src/central/concierge/board-results.ts +169 -0
  123. package/src/central/concierge/board-routes.ts +339 -0
  124. package/src/central/concierge/board.ts +249 -0
  125. package/src/central/concierge/context.ts +221 -0
  126. package/src/central/concierge/entitlements.ts +13 -0
  127. package/src/central/concierge/guard.ts +120 -0
  128. package/src/central/concierge/inbound.ts +89 -0
  129. package/src/central/concierge/index.ts +84 -0
  130. package/src/central/concierge/notes.ts +41 -0
  131. package/src/central/concierge/notifier.ts +986 -0
  132. package/src/central/concierge/prompt.ts +130 -0
  133. package/src/central/concierge/proposals.ts +262 -0
  134. package/src/central/concierge/push-remap.ts +49 -0
  135. package/src/central/concierge/routes.ts +62 -0
  136. package/src/central/concierge/schedule.ts +113 -0
  137. package/src/central/concierge/service.ts +243 -0
  138. package/src/central/concierge/session-routes.ts +69 -0
  139. package/src/central/concierge/settings.ts +217 -0
  140. package/src/central/concierge/supervisor.ts +740 -0
  141. package/src/central/concierge/tiers.ts +260 -0
  142. package/src/central/concierge/watchlist-routes.ts +277 -0
  143. package/src/central/concierge/watchlist.ts +273 -0
  144. package/src/central/config.ts +7 -1
  145. package/src/central/http.ts +633 -50
  146. package/src/central/mcp-http.ts +10 -21
  147. package/src/central/profiles/apply.ts +333 -0
  148. package/src/central/profiles/content.ts +174 -0
  149. package/src/central/profiles/events.ts +40 -0
  150. package/src/central/profiles/idempotency.ts +14 -0
  151. package/src/central/profiles/import.ts +139 -0
  152. package/src/central/profiles/routes.ts +356 -0
  153. package/src/central/profiles/store.ts +597 -0
  154. package/src/central/profiles/usage-ports.ts +120 -0
  155. package/src/central/profiles/usage.ts +364 -0
  156. package/src/central/push.ts +7 -0
  157. package/src/central/registry.ts +43 -6
  158. package/src/central/store.ts +4 -0
  159. package/src/central/teams/collections.ts +34 -0
  160. package/src/central/teams/discovery.ts +346 -0
  161. package/src/central/teams/draft-edits.ts +46 -0
  162. package/src/central/teams/events.ts +46 -0
  163. package/src/central/teams/intake-routes.ts +100 -0
  164. package/src/central/teams/intake.ts +772 -0
  165. package/src/central/teams/policy.ts +231 -0
  166. package/src/central/teams/routes.ts +516 -0
  167. package/src/central/teams/seats.ts +272 -0
  168. package/src/central/teams/staffing.ts +888 -0
  169. package/src/central/teams/store.ts +667 -0
  170. package/src/central/teams/validation.ts +256 -0
  171. package/src/central/workspaces/board.ts +408 -0
  172. package/src/central/workspaces/checkpoints.ts +316 -0
  173. package/src/central/workspaces/coordinator.ts +841 -0
  174. package/src/central/workspaces/events.ts +85 -0
  175. package/src/central/workspaces/lifecycle.ts +93 -0
  176. package/src/central/workspaces/master.ts +490 -0
  177. package/src/central/workspaces/members.ts +632 -0
  178. package/src/central/workspaces/routes.ts +354 -0
  179. package/src/central/workspaces/sidechats-routes.ts +410 -0
  180. package/src/central/workspaces/sidechats.ts +482 -0
  181. package/src/central/workspaces/store.ts +571 -0
  182. package/src/central/workspaces/supervision.ts +355 -0
  183. package/src/index.ts +97 -15
  184. package/src/spoke/concierge-agent.ts +33 -0
  185. package/src/spoke/deliver.ts +22 -4
  186. package/src/spoke/fork-revert.ts +142 -0
  187. package/src/spoke/hub-rpc.ts +41 -5
  188. package/src/spoke/hub-tool.ts +383 -18
  189. package/src/spoke/listing.ts +47 -4
  190. package/src/spoke/profile-apply.ts +298 -0
  191. package/src/spoke/session-context.ts +176 -0
  192. package/src/spoke/spoke.ts +319 -20
  193. package/src/spoke/timeline.ts +6 -1
  194. package/src/spoke/tracker.ts +23 -1
  195. package/src/spoke/who-teams.ts +81 -0
  196. package/src/tui-messaging.ts +32 -18
  197. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
  198. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
  199. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  200. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
  201. package/web/dist/assets/Library-CtxRqTjo.js +2 -0
  202. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  203. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
  204. package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
  205. package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
  206. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  207. package/web/dist/assets/index-DaH5GUdV.js +31 -0
  208. package/web/dist/assets/index-WWuKfWxL.css +1 -0
  209. package/web/dist/assets/live-BUofsQpz.css +1 -0
  210. package/web/dist/assets/live-YyTjE47g.js +5 -0
  211. package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
  212. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  213. package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
  214. package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
  215. package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
  216. package/web/dist/assets/source-CvUHTc3O.js +1 -0
  217. package/web/dist/assets/ui-C9Igxzjm.js +1 -0
  218. package/web/dist/index.html +5 -2
  219. package/web/dist/sw.js +22 -2
  220. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  221. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -0,0 +1,162 @@
1
+ // Capabilities of the isolated artifact host (HWA-26; contract: Cross-cutting "Trust boundary", ADR in Brain).
2
+ //
3
+ // The artifact host serves an html/app revision's files at /artifact-host/v1/<token>/<path> with no ambient
4
+ // credentials: no cookie, no bearer, only the token in the path. A token is
5
+ //
6
+ // ahc1.<kid>.<base64url(JSON {c: capID, u: userID, a: artifactID, r: revisionID, e: expiresAt})>.<base64url(sig)>
7
+ //
8
+ // where sig = HMAC-SHA256(secret of kid, "ahc1.<kid>.<payload>"), signed with the hub's token keys (the "ahc1."
9
+ // prefix separates it from every other token those keys sign). The payload names a record in the per-user
10
+ // collection ARTIFACT_HOST_CAPS, which holds what the token may never assert by itself: the viewer it was minted
11
+ // for (the user or a session, and the device) and whether it was revoked. So a capability is
12
+ // - short-lived: expiresAt is in the signed payload and in the record (min/max TTL below);
13
+ // - single-revision: one artifactID + revisionID, signed;
14
+ // - viewer-bound: the host re-checks the *viewer's* read access to that revision on every request (a session
15
+ // that leaves the workspace, an archived/deleted artifact or a removed device ends it at once);
16
+ // - revocable: revoke() marks the record; a missing record (deleted, pruned, another user's) fails closed.
17
+ // Records live in the StateStore (file-state standalone, Postgres cluster), so every pod verifies every token and
18
+ // they survive restarts. Secrets and tokens are never logged.
19
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"
20
+ import { AgentError } from "../agent/errors"
21
+ import type { StateStore } from "../backend/types"
22
+ import type { ArtifactPrincipal } from "./access"
23
+
24
+ export const ARTIFACT_HOST_CAPS = "artifact-host-caps"
25
+
26
+ export const ARTIFACT_HOST_LIMITS = {
27
+ /** A capability's lifetime when the minter names none. */
28
+ defaultTtlMs: 15 * 60_000,
29
+ minTtlMs: 5_000,
30
+ maxTtlMs: 60 * 60_000,
31
+ /** Unexpired capabilities one user may hold (a soft cap: concurrent mints may overshoot by a few). */
32
+ liveCapsPerUser: 64,
33
+ /** Characters of a token (anything longer is refused unparsed). */
34
+ tokenChars: 1024,
35
+ } as const
36
+
37
+ const PREFIX = "ahc1"
38
+ const B64URL = /^[A-Za-z0-9_-]+$/
39
+
40
+ /** The hub's signing keys (TokenKeys implements it). */
41
+ export interface HostSigningKeys {
42
+ active(): { kid: string; secret: Buffer }
43
+ key(kid: string): Buffer | undefined
44
+ }
45
+
46
+ export interface StoredHostCap {
47
+ readonly v: 1
48
+ readonly id: string
49
+ readonly artifactID: string
50
+ readonly revisionID: string
51
+ /** Who minted it: the host reads the revision as this principal. */
52
+ readonly viewer: ArtifactPrincipal
53
+ /** The minting device (console users), re-checked on use when the hub can. */
54
+ readonly deviceID?: string
55
+ readonly createdAt: number
56
+ readonly expiresAt: number
57
+ readonly revokedAt?: number
58
+ }
59
+
60
+ export interface MintInput {
61
+ readonly artifactID: string
62
+ readonly revisionID: string
63
+ readonly ttlMs?: number
64
+ readonly deviceID?: string
65
+ }
66
+
67
+ export interface ArtifactHostCapsOptions {
68
+ readonly state: StateStore
69
+ readonly keys: HostSigningKeys
70
+ readonly now?: () => number
71
+ }
72
+
73
+ interface Payload {
74
+ c: string
75
+ u: string
76
+ a: string
77
+ r: string
78
+ e: number
79
+ }
80
+
81
+ const sign = (secret: Buffer, kid: string, payload: string): string => createHmac("sha256", secret).update(`${PREFIX}.${kid}.${payload}`).digest("base64url")
82
+
83
+ const sameViewer = (a: ArtifactPrincipal, b: ArtifactPrincipal): boolean => a.kind === b.kind && (a.kind === "user" || (b.kind === "session" && a.sessionID === b.sessionID))
84
+
85
+ export class ArtifactHostCaps {
86
+ private readonly state: StateStore
87
+ private readonly keys: HostSigningKeys
88
+ private readonly now: () => number
89
+
90
+ constructor(options: ArtifactHostCapsOptions) {
91
+ this.state = options.state
92
+ this.keys = options.keys
93
+ this.now = options.now ?? Date.now
94
+ }
95
+
96
+ /** A new capability for one revision as `viewer`; prunes the user's expired ones and enforces the live cap. */
97
+ async mint(userID: string, viewer: ArtifactPrincipal, input: MintInput): Promise<{ token: string; capability: { id: string; expiresAt: number } }> {
98
+ const now = this.now()
99
+ let live = 0
100
+ for (const entry of await this.state.list<StoredHostCap>(userID, ARTIFACT_HOST_CAPS)) {
101
+ if (entry.value.expiresAt <= now || entry.value.revokedAt !== undefined) await this.state.delete(userID, ARTIFACT_HOST_CAPS, entry.key)
102
+ else live++
103
+ }
104
+ if (live >= ARTIFACT_HOST_LIMITS.liveCapsPerUser) {
105
+ throw new AgentError("rate_limited", `At most ${ARTIFACT_HOST_LIMITS.liveCapsPerUser} open artifact views at once; close some first`, { extra: { limit: ARTIFACT_HOST_LIMITS.liveCapsPerUser } })
106
+ }
107
+ const ttl = Math.min(ARTIFACT_HOST_LIMITS.maxTtlMs, Math.max(ARTIFACT_HOST_LIMITS.minTtlMs, input.ttlMs ?? ARTIFACT_HOST_LIMITS.defaultTtlMs))
108
+ const id = `ahc_${randomBytes(12).toString("base64url")}`
109
+ const cap: StoredHostCap = {
110
+ v: 1,
111
+ id,
112
+ artifactID: input.artifactID,
113
+ revisionID: input.revisionID,
114
+ viewer: viewer.kind === "user" ? { kind: "user" } : { kind: "session", sessionID: viewer.sessionID },
115
+ ...(input.deviceID !== undefined ? { deviceID: input.deviceID } : {}),
116
+ createdAt: now,
117
+ expiresAt: now + ttl,
118
+ }
119
+ await this.state.put(userID, ARTIFACT_HOST_CAPS, id, cap)
120
+ const { kid, secret } = this.keys.active()
121
+ const payload = Buffer.from(JSON.stringify({ c: id, u: userID, a: cap.artifactID, r: cap.revisionID, e: cap.expiresAt } satisfies Payload)).toString("base64url")
122
+ return { token: `${PREFIX}.${kid}.${payload}.${sign(secret, kid, payload)}`, capability: { id, expiresAt: cap.expiresAt } }
123
+ }
124
+
125
+ /** The user and record of a valid token; undefined for anything else (malformed, forged, expired, revoked). */
126
+ async verify(token: string): Promise<{ userID: string; cap: StoredHostCap } | undefined> {
127
+ if (typeof token !== "string" || token.length > ARTIFACT_HOST_LIMITS.tokenChars) return undefined
128
+ const parts = token.split(".")
129
+ if (parts.length !== 4) return undefined
130
+ const [prefix, kid, payload, sig] = parts as [string, string, string, string]
131
+ if (prefix !== PREFIX || !B64URL.test(kid) || !B64URL.test(payload) || !B64URL.test(sig)) return undefined
132
+ const secret = this.keys.key(kid)
133
+ if (!secret) return undefined
134
+ const expected = Buffer.from(sign(secret, kid, payload))
135
+ const given = Buffer.from(sig)
136
+ if (expected.length !== given.length || !timingSafeEqual(expected, given)) return undefined
137
+ let body: Payload
138
+ try {
139
+ body = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Payload
140
+ } catch {
141
+ return undefined
142
+ }
143
+ if (typeof body?.c !== "string" || typeof body.u !== "string" || typeof body.a !== "string" || typeof body.r !== "string" || typeof body.e !== "number") return undefined
144
+ const now = this.now()
145
+ if (body.e <= now) return undefined
146
+ const stored = await this.state.get<StoredHostCap>(body.u, ARTIFACT_HOST_CAPS, body.c)
147
+ const cap = stored?.value
148
+ if (!cap || cap.id !== body.c || cap.artifactID !== body.a || cap.revisionID !== body.r || cap.expiresAt !== body.e) return undefined
149
+ if (cap.revokedAt !== undefined || cap.expiresAt <= now) return undefined
150
+ return { userID: body.u, cap }
151
+ }
152
+
153
+ /** Revokes one capability of `userID`: the user may revoke any, a session only its own. False when none. */
154
+ async revoke(userID: string, viewer: ArtifactPrincipal, id: string): Promise<boolean> {
155
+ if (typeof id !== "string" || !/^ahc_[A-Za-z0-9_-]{16}$/.test(id)) return false
156
+ const stored = await this.state.get<StoredHostCap>(userID, ARTIFACT_HOST_CAPS, id)
157
+ if (!stored || stored.value.revokedAt !== undefined) return false
158
+ if (viewer.kind === "session" && !sameViewer(stored.value.viewer, viewer)) return false
159
+ await this.state.put(userID, ARTIFACT_HOST_CAPS, id, { ...stored.value, revokedAt: this.now() })
160
+ return true
161
+ }
162
+ }
@@ -0,0 +1,315 @@
1
+ // The isolated artifact host (HWA-26; contract: Cross-cutting "Trust boundary"; threat model in the Brain ADR and
2
+ // docs/artifact-host.md). Interactive artifacts (ISOLATED_KINDS: html, app) run only here, never in the console
3
+ // bundle and never in central: the hub serves the static bytes it stored and nothing else.
4
+ //
5
+ // GET /artifact-host/v1/<token>/<path> one file of the token's revision (no cookie or bearer used)
6
+ // GET /artifact-host/v1/<token>/…/__hub/bridge.js the first-party bridge client (window.hubArtifact)
7
+ // POST /api/agent/artifacts/:id/revisions/:rev/host {ttlSeconds?} → {capability, base, url, files}
8
+ // DELETE /api/agent/artifact-host/:cap revoke (the minting viewer, or the user)
9
+ //
10
+ // Isolation, out of the box (no new hostname, DNS or TLS): every host response carries
11
+ // `Content-Security-Policy: sandbox allow-scripts; …` and the console frames it with `sandbox="allow-scripts"`
12
+ // (never allow-same-origin), so the document always gets a fresh *opaque* origin, even opened top-level. It can't
13
+ // read the hub's cookies or the console's storage, and the browser treats its requests as cross-site (the hub's
14
+ // SameSite=Lax cookie is not attached). No popups, top navigation, forms, modals or downloads (not allowed by the
15
+ // sandbox), no plugins, workers or nested frames, and no network except this capability's own path prefix
16
+ // (connect/script/style/img/font/media-src are path-scoped to `<origin>/artifact-host/v1/<token>/`). Only the
17
+ // console origins may frame it (frame-ancestors). Bytes are served as declared (never sniffed: nosniff).
18
+ // Authority: the token only (./host-caps.ts), re-checked as the minting viewer on every request, and rate limited
19
+ // per capability. CORS answers `*` without credentials so module scripts load from the opaque origin.
20
+ import { boundedText } from "../agent/body"
21
+ import { type ArtifactKind, ISOLATED_KINDS, type BlobRef } from "../../protocol"
22
+ import type { AgentActor } from "../agent/actor"
23
+ import { AgentError } from "../agent/errors"
24
+ // Types only: ../agent/routes imports the artifact modules (a value import would be circular).
25
+ import type { AgentRoute } from "../agent/routes"
26
+ import type { ArtifactPrincipal } from "./access"
27
+ import { ARTIFACT_HOST_LIMITS, type ArtifactHostCaps } from "./host-caps"
28
+ import type { ArtifactStore } from "./store"
29
+
30
+ export const ARTIFACT_HOST_PATH = "/artifact-host/v1/"
31
+ /** The bridge client, served at this path under any directory of a capability. Bundle files there are shadowed. */
32
+ export const BRIDGE_CLIENT_PATH = "__hub/bridge.js"
33
+ /** Must equal BRIDGE_PROTOCOL / BRIDGE_VERSION in web/src/core/artifact-bridge.ts (a test checks). */
34
+ export const BRIDGE_PROTOCOL = "hub.artifact-bridge"
35
+ export const BRIDGE_VERSION = 1
36
+
37
+ export const ARTIFACT_HOST_LIMITS_HTTP = {
38
+ ...ARTIFACT_HOST_LIMITS,
39
+ /** Host requests one capability may make per minute (per hub process). */
40
+ requestsPerMinute: 600,
41
+ /** Capabilities tracked by the rate limiter before old windows are dropped. */
42
+ trackedCaps: 10_000,
43
+ /** A mint request body. */
44
+ mintBodyBytes: 1024,
45
+ } as const
46
+
47
+ export const isArtifactHostPath = (pathname: string): boolean => pathname === "/artifact-host" || pathname.startsWith("/artifact-host/")
48
+
49
+ const FILE_PATH = /^(?!\/)(?!.*(?:^|\/)\.\.?(?:\/|$))(?!.*\/\/)[A-Za-z0-9._/-]{1,200}$/
50
+ const BRIDGE_AT = /^(?:[A-Za-z0-9._-]+\/)*__hub\/bridge\.js$/
51
+ const TOKEN = /^[A-Za-z0-9._-]{1,1024}$/
52
+ const PERMISSIONS =
53
+ "accelerometer=(), autoplay=(), camera=(), clipboard-read=(), clipboard-write=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), hid=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), serial=(), usb=(), xr-spatial-tracking=()"
54
+
55
+ const unique = (values: readonly string[]): string[] => [...new Set(values)]
56
+
57
+ /** The host's Content-Security-Policy: sandboxed, nothing by default, sources only under the capability's path. */
58
+ export function hostCsp(bases: readonly string[], ancestors: readonly string[]): string {
59
+ const b = unique(bases).join(" ")
60
+ return [
61
+ "sandbox allow-scripts",
62
+ "default-src 'none'",
63
+ `script-src ${b} 'unsafe-inline'`,
64
+ `style-src ${b} 'unsafe-inline'`,
65
+ `img-src ${b} data: blob:`,
66
+ `font-src ${b} data:`,
67
+ `media-src ${b} data: blob:`,
68
+ `connect-src ${b}`,
69
+ "frame-src 'none'",
70
+ "worker-src 'none'",
71
+ "object-src 'none'",
72
+ "manifest-src 'none'",
73
+ "form-action 'none'",
74
+ "base-uri 'none'",
75
+ `frame-ancestors ${ancestors.length ? unique(ancestors).join(" ") : "'none'"}`,
76
+ ].join("; ")
77
+ }
78
+
79
+ /** For a response without a capability (errors): nothing at all may load or run. */
80
+ const LOCKED_CSP = "sandbox; default-src 'none'; frame-ancestors 'none'"
81
+
82
+ /**
83
+ * The bridge client the artifact loads from `__hub/bridge.js`: window.hubArtifact. It accepts the init message only
84
+ * from its parent window (source check) on a console origin (origin check), then talks only on that channel to
85
+ * that origin. Replies are matched by request ID. First-party code; the artifact may also speak the protocol itself.
86
+ */
87
+ export function bridgeClientScript(parents: readonly string[]): string {
88
+ return `// Hub artifact bridge client (HWA-26). Protocol ${BRIDGE_PROTOCOL} v${BRIDGE_VERSION}.
89
+ (() => {
90
+ "use strict";
91
+ if (window.hubArtifact) return;
92
+ const P = ${JSON.stringify(BRIDGE_PROTOCOL)}, V = ${BRIDGE_VERSION}, PARENTS = ${JSON.stringify(unique(parents))};
93
+ const up = window.parent;
94
+ let channel, origin, info, seq = 0;
95
+ const pending = new Map(), waiting = [];
96
+ addEventListener("message", (e) => {
97
+ if (up === window || e.source !== up || !PARENTS.includes(e.origin)) return;
98
+ const m = e.data;
99
+ if (!m || typeof m !== "object" || m.p !== P || m.v !== V) return;
100
+ if (m.type === "init") {
101
+ if (channel !== undefined || typeof m.channel !== "string" || !/^[0-9a-f]{32}$/.test(m.channel)) return;
102
+ channel = m.channel;
103
+ origin = e.origin;
104
+ info = Object.freeze({ artifactID: String(m.artifactID), revisionID: String(m.revisionID), files: Object.freeze((Array.isArray(m.files) ? m.files : []).map((f) => Object.freeze({ ...f }))), capabilities: Object.freeze([...(m.capabilities || [])]) });
105
+ for (const resolve of waiting.splice(0)) resolve(info);
106
+ return;
107
+ }
108
+ if (m.channel !== channel || m.type !== "reply" || typeof m.id !== "string") return;
109
+ const p = pending.get(m.id);
110
+ if (!p) return;
111
+ pending.delete(m.id);
112
+ if (m.ok) p.resolve(m.result);
113
+ else p.reject(Object.assign(new Error(String((m.error && m.error.message) || "refused")), { code: m.error && m.error.code }));
114
+ });
115
+ const post = (message) => up.postMessage(Object.assign({ p: P, v: V, channel }, message), origin);
116
+ const ready = () => (info ? Promise.resolve(info) : new Promise((resolve) => waiting.push(resolve)));
117
+ const request = async (type, body) => {
118
+ await ready();
119
+ const id = "r" + ++seq;
120
+ return new Promise((resolve, reject) => {
121
+ pending.set(id, { resolve, reject });
122
+ post(Object.assign({ type, id }, body));
123
+ });
124
+ };
125
+ const resize = async (height) => {
126
+ await ready();
127
+ post({ type: "resize", height: Math.ceil(Number(height) || 0) });
128
+ };
129
+ const autoResize = () => {
130
+ const report = () => resize(document.documentElement.scrollHeight);
131
+ new ResizeObserver(report).observe(document.documentElement);
132
+ report();
133
+ };
134
+ window.hubArtifact = Object.freeze({ version: V, ready, resize, autoResize, askAI: (instruction) => request("askAI", { instruction: String(instruction) }) });
135
+ })();
136
+ `
137
+ }
138
+
139
+ export interface ArtifactHostDeps {
140
+ readonly caps: ArtifactHostCaps
141
+ /** Absent: this hub keeps no artifacts (every host path answers 404). */
142
+ readonly artifacts?: Pick<ArtifactStore, "get" | "getRevision" | "readBlob">
143
+ /** Origins the console is served from: the only frame ancestors and the bridge's accepted parents. */
144
+ readonly consoleOrigins: () => readonly string[]
145
+ /** False when a capability's minting device no longer exists (unpaired): the capability stops working. */
146
+ readonly deviceActive?: (userID: string, deviceID: string) => boolean
147
+ readonly now?: () => number
148
+ }
149
+
150
+ const isolated = (kind: ArtifactKind): boolean => ISOLATED_KINDS.includes(kind)
151
+
152
+ /** The file a host path names in a revision: its bundle path, or "" for the one unnamed file. */
153
+ function fileAt(blobs: readonly BlobRef[], rest: string): BlobRef | undefined {
154
+ if (rest === "") return blobs.length === 1 && blobs[0]!.path === undefined ? blobs[0] : undefined
155
+ return blobs.find((b) => b.path === rest)
156
+ }
157
+
158
+ const contentType = (mediaType: string): string => (mediaType.startsWith("text/") ? `${mediaType}; charset=utf-8` : mediaType)
159
+
160
+ export function artifactHost(deps: ArtifactHostDeps): { handle(request: Request, url: URL): Promise<Response> } {
161
+ const now = deps.now ?? Date.now
162
+ const windows = new Map<string, { start: number; count: number }>()
163
+
164
+ const refuse = (status: number, text: string, extra: Record<string, string> = {}) =>
165
+ new Response(text, {
166
+ status,
167
+ headers: { "content-type": "text/plain; charset=utf-8", "content-security-policy": LOCKED_CSP, "x-content-type-options": "nosniff", "referrer-policy": "no-referrer", "cache-control": "no-store", ...extra },
168
+ })
169
+ const notFound = () => refuse(404, "Not found (the link may have expired or been revoked)")
170
+
171
+ /** False when the capability spent its requests for this minute. */
172
+ const spend = (capID: string): boolean => {
173
+ const t = now()
174
+ let w = windows.get(capID)
175
+ if (!w || t - w.start >= 60_000) {
176
+ if (!w && windows.size >= ARTIFACT_HOST_LIMITS_HTTP.trackedCaps) {
177
+ for (const [id, old] of windows) if (t - old.start >= 60_000) windows.delete(id)
178
+ if (windows.size >= ARTIFACT_HOST_LIMITS_HTTP.trackedCaps) windows.delete(windows.keys().next().value!)
179
+ }
180
+ w = { start: t, count: 0 }
181
+ windows.set(capID, w)
182
+ }
183
+ return ++w.count <= ARTIFACT_HOST_LIMITS_HTTP.requestsPerMinute
184
+ }
185
+
186
+ return {
187
+ async handle(request, url) {
188
+ if (request.method !== "GET" && request.method !== "HEAD") return refuse(405, "Method not allowed", { allow: "GET, HEAD" })
189
+ if (!url.pathname.startsWith(ARTIFACT_HOST_PATH)) return notFound()
190
+ const tail = url.pathname.slice(ARTIFACT_HOST_PATH.length)
191
+ const slash = tail.indexOf("/")
192
+ if (slash <= 0) return notFound()
193
+ const token = tail.slice(0, slash)
194
+ const rest = tail.slice(slash + 1)
195
+ if (!TOKEN.test(token)) return notFound()
196
+ const isBridge = BRIDGE_AT.test(rest)
197
+ if (rest !== "" && !isBridge && !FILE_PATH.test(rest)) return notFound()
198
+ const verified = await deps.caps.verify(token)
199
+ if (!verified || !deps.artifacts) return notFound()
200
+ const { userID, cap } = verified
201
+ if (!spend(cap.id)) return refuse(429, "Too many requests", { "retry-after": "60" })
202
+ if (cap.deviceID !== undefined && deps.deviceActive && !deps.deviceActive(userID, cap.deviceID)) return notFound()
203
+ const origins = deps.consoleOrigins()
204
+ const ancestors = unique([url.origin, ...origins])
205
+ const base = `${ARTIFACT_HOST_PATH}${token}/`
206
+ const headers: Record<string, string> = {
207
+ "content-security-policy": hostCsp(ancestors.map((o) => `${o}${base}`), ancestors),
208
+ "x-content-type-options": "nosniff",
209
+ "referrer-policy": "no-referrer",
210
+ "cache-control": "private, no-store",
211
+ "permissions-policy": PERMISSIONS,
212
+ "access-control-allow-origin": "*",
213
+ "cross-origin-opener-policy": "same-origin",
214
+ }
215
+ try {
216
+ // Re-checked as the minting viewer on every request: a revoked read ends the capability at once.
217
+ const artifact = await deps.artifacts.get(userID, cap.viewer, cap.artifactID)
218
+ if (!isolated(artifact.kind)) return notFound()
219
+ if (isBridge) {
220
+ const body = request.method === "HEAD" ? null : bridgeClientScript(ancestors)
221
+ return new Response(body, { status: 200, headers: { ...headers, "content-type": "text/javascript; charset=utf-8" } })
222
+ }
223
+ const revision = await deps.artifacts.getRevision(userID, cap.viewer, cap.artifactID, cap.revisionID)
224
+ const ref = fileAt(revision.blobs, rest)
225
+ if (!ref) return notFound()
226
+ const { blob } = await deps.artifacts.readBlob(userID, cap.viewer, cap.artifactID, cap.revisionID, ref.hash)
227
+ if (request.method === "HEAD") await blob.body.cancel().catch(() => {})
228
+ return new Response(request.method === "HEAD" ? null : blob.body, {
229
+ status: 200,
230
+ headers: { ...headers, "content-type": contentType(ref.mediaType), "content-length": String(blob.bytes), etag: `"${ref.hash}"` },
231
+ })
232
+ } catch (error) {
233
+ if (error instanceof AgentError && (error.code === "not_found" || error.code === "forbidden")) return notFound()
234
+ throw error
235
+ }
236
+ },
237
+ }
238
+ }
239
+
240
+ // ---- agent routes: mint and revoke -----------------------------------------------------------------------
241
+
242
+ export interface ArtifactHostRouteDeps {
243
+ readonly caps: ArtifactHostCaps
244
+ /** Absent: this hub keeps no artifacts (422 unsupported). */
245
+ readonly artifacts?: Pick<ArtifactStore, "get" | "getRevision">
246
+ }
247
+
248
+ const READ = { session: true, scope: "artifacts:read" } as const
249
+ const NO_STORE = { "cache-control": "no-store" }
250
+ const invalid = (message: string) => new AgentError("invalid", message)
251
+ const principalOf = (actor: AgentActor): ArtifactPrincipal => (actor.kind === "session" ? { kind: "session", sessionID: actor.sessionID } : { kind: "user" })
252
+
253
+ async function mintBody(request: Request): Promise<{ ttlMs?: number }> {
254
+ const max = ARTIFACT_HOST_LIMITS_HTTP.mintBodyBytes
255
+ // Bounded in bytes and time, the drain too (agent/body.ts): "The body is larger than <max> bytes".
256
+ const text = await boundedText(request, max)
257
+ if (text.trim() === "") return {}
258
+ let body: unknown
259
+ try {
260
+ body = JSON.parse(text)
261
+ } catch {
262
+ throw invalid("Invalid JSON")
263
+ }
264
+ if (typeof body !== "object" || body === null || Array.isArray(body)) throw invalid("Expected a JSON object")
265
+ const extra = Object.keys(body).find((k) => k !== "ttlSeconds")
266
+ if (extra !== undefined) throw invalid(`The body has an unknown field ${JSON.stringify(extra.slice(0, 50))}`)
267
+ const ttl = (body as { ttlSeconds?: unknown }).ttlSeconds
268
+ if (ttl === undefined) return {}
269
+ const maxSeconds = ARTIFACT_HOST_LIMITS.maxTtlMs / 1000
270
+ if (typeof ttl !== "number" || !Number.isSafeInteger(ttl) || ttl < 1 || ttl > maxSeconds) throw invalid(`ttlSeconds must be a whole number 1–${maxSeconds}`)
271
+ return { ttlMs: ttl * 1000 }
272
+ }
273
+
274
+ export function artifactHostRoutes(deps: ArtifactHostRouteDeps): AgentRoute[] {
275
+ const store = () => {
276
+ if (!deps.artifacts) throw new AgentError("unsupported", "This hub does not store artifacts", { extra: { feature: "artifacts" } })
277
+ return deps.artifacts
278
+ }
279
+ return [
280
+ {
281
+ method: "POST",
282
+ path: "/api/agent/artifacts/:id/revisions/:rev/host",
283
+ allow: READ,
284
+ async handle({ user, actor, params, request }) {
285
+ const artifacts = store()
286
+ const { ttlMs } = await mintBody(request)
287
+ const principal = principalOf(actor)
288
+ const artifact = await artifacts.get(user.id, principal, params.id!)
289
+ if (!isolated(artifact.kind)) throw invalid(`Only ${ISOLATED_KINDS.join(" and ")} artifacts run in the artifact host; ${artifact.kind} renders in the console`)
290
+ const revision = await artifacts.getRevision(user.id, principal, artifact.id, params.rev!)
291
+ const entry = revision.entry ?? ""
292
+ const entryFile = fileAt(revision.blobs, entry)
293
+ if (!entryFile || entryFile.mediaType !== "text/html") throw invalid("The revision's entry must be an HTML file")
294
+ const { token, capability } = await deps.caps.mint(user.id, principal, {
295
+ artifactID: artifact.id,
296
+ revisionID: revision.id,
297
+ ...(ttlMs !== undefined ? { ttlMs } : {}),
298
+ ...(actor.kind === "user" ? { deviceID: actor.deviceID } : {}),
299
+ })
300
+ const base = `${ARTIFACT_HOST_PATH}${token}/`
301
+ const files = revision.blobs.map((b) => ({ ...(b.path !== undefined ? { path: b.path } : {}), mediaType: b.mediaType, bytes: b.bytes }))
302
+ return Response.json({ capability, base, url: `${base}${entry}`, files }, { status: 201, headers: NO_STORE })
303
+ },
304
+ },
305
+ {
306
+ method: "DELETE",
307
+ path: "/api/agent/artifact-host/:cap",
308
+ allow: READ,
309
+ async handle({ user, actor, params }) {
310
+ if (!(await deps.caps.revoke(user.id, principalOf(actor), params.cap!))) throw new AgentError("not_found", "No such artifact view")
311
+ return new Response(null, { status: 204, headers: NO_STORE })
312
+ },
313
+ },
314
+ ]
315
+ }
@@ -0,0 +1,109 @@
1
+ // Idempotency-Key for the artifact creating routes (HWA-21; contract: Cross-cutting rules, Idempotency): per user and
2
+ // per route, IDEMPOTENCY_TTL_MS. The same key and body answer the original result (created false); another body under
3
+ // the same key is 409 conflict. Stored in the StateStore collection "artifact-idempotency":
4
+ // `<route>|<key>` → { v, digest, at, state: "pending" | "done", artifactID?, revisionID? }
5
+ // A key is claimed (create-only write) before the work runs and released when the work fails, so two concurrent
6
+ // requests with one key never both publish: the second sees a pending claim and answers 429 rate_limited
7
+ // (retryAfter). A pending claim older than PENDING_MS belongs to a writer that crashed and is taken over.
8
+ import { createHash } from "node:crypto"
9
+ import { IDEMPOTENCY_KEY, IDEMPOTENCY_TTL_MS } from "../../protocol"
10
+ import { AgentError } from "../agent/errors"
11
+ import type { Clock, StateStore } from "../backend/types"
12
+
13
+ export const ARTIFACT_IDEMPOTENCY = "artifact-idempotency"
14
+ /** How long a claim may stay pending before another request takes it over. */
15
+ export const PENDING_MS = 5 * 60_000
16
+ /** Keys kept per user beyond expiry pruning (oldest dropped first). */
17
+ export const IDEMPOTENCY_KEYS_MAX = 2_000
18
+ const ATTEMPTS = 8
19
+
20
+ export interface IdempotentResult {
21
+ readonly artifactID: string
22
+ readonly revisionID: string
23
+ }
24
+
25
+ interface Claim {
26
+ readonly v: 1
27
+ readonly digest: string
28
+ readonly at: number
29
+ readonly state: "pending" | "done"
30
+ readonly artifactID?: string
31
+ readonly revisionID?: string
32
+ }
33
+
34
+ /** A stable digest of a JSON body (object keys sorted). */
35
+ export function bodyDigest(body: unknown): string {
36
+ return createHash("sha256").update(canonical(body)).digest("hex")
37
+ }
38
+
39
+ function canonical(value: unknown): string {
40
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
41
+ if (value && typeof value === "object") {
42
+ const entries = Object.entries(value as Record<string, unknown>).filter(([, v]) => v !== undefined)
43
+ entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
44
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`
45
+ }
46
+ return JSON.stringify(value) ?? "null"
47
+ }
48
+
49
+ export class ArtifactIdempotency {
50
+ constructor(
51
+ private readonly state: StateStore,
52
+ private readonly now: Clock = Date.now,
53
+ ) {}
54
+
55
+ /**
56
+ * Runs `work` once per (user, route, key, body): a repeat answers `replay(original)`. Without a key the work just
57
+ * runs. `work` must resolve with the IDs that identify its result.
58
+ */
59
+ async run<T>(
60
+ userID: string,
61
+ route: string,
62
+ key: string | null | undefined,
63
+ body: unknown,
64
+ work: () => Promise<{ readonly ids: IdempotentResult; readonly value: T }>,
65
+ replay: (ids: IdempotentResult) => Promise<T>,
66
+ ): Promise<{ value: T; created: boolean }> {
67
+ if (key === null || key === undefined) return { value: (await work()).value, created: true }
68
+ if (!IDEMPOTENCY_KEY.test(key)) throw new AgentError("invalid", "Idempotency-Key must be 1–200 of A-Z a-z 0-9 _ . : -")
69
+ const digest = bodyDigest(body)
70
+ const id = `${route}|${key}`
71
+ await this.prune(userID)
72
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
73
+ const held = await this.state.get<Claim>(userID, ARTIFACT_IDEMPOTENCY, id)
74
+ const live = held && this.now() - held.value.at < IDEMPOTENCY_TTL_MS ? held.value : undefined
75
+ if (live) {
76
+ if (live.digest !== digest) throw new AgentError("conflict", `Idempotency-Key ${key} was used with a different body`, { hint: "Use a new key, or resend the original body" })
77
+ if (live.state === "done" && live.artifactID && live.revisionID) return { value: await replay({ artifactID: live.artifactID, revisionID: live.revisionID }), created: false }
78
+ if (this.now() - live.at < PENDING_MS) throw new AgentError("rate_limited", `The request with Idempotency-Key ${key} is still running`, { extra: { retryAfter: 1 } })
79
+ // Its writer crashed: take the key over.
80
+ }
81
+ const claim: Claim = { v: 1, digest, at: this.now(), state: "pending" }
82
+ const claimed = await this.state.put<Claim>(userID, ARTIFACT_IDEMPOTENCY, id, claim, { ifVersion: held?.version ?? 0 })
83
+ if (!claimed.ok) continue
84
+ let result: { ids: IdempotentResult; value: T }
85
+ try {
86
+ result = await work()
87
+ } catch (error) {
88
+ await this.state.delete(userID, ARTIFACT_IDEMPOTENCY, id, { ifVersion: claimed.version }).catch(() => {})
89
+ throw error
90
+ }
91
+ await this.state
92
+ .put<Claim>(userID, ARTIFACT_IDEMPOTENCY, id, { ...claim, state: "done", ...result.ids }, { ifVersion: claimed.version })
93
+ .catch(() => {})
94
+ return { value: result.value, created: true }
95
+ }
96
+ throw new AgentError("rate_limited", `Idempotency-Key ${key} is contended; retry`, { extra: { retryAfter: 1 } })
97
+ }
98
+
99
+ /** Drops expired keys, and the oldest ones past IDEMPOTENCY_KEYS_MAX. */
100
+ private async prune(userID: string): Promise<void> {
101
+ const entries = await this.state.list<Claim>(userID, ARTIFACT_IDEMPOTENCY)
102
+ const cutoff = this.now() - IDEMPOTENCY_TTL_MS
103
+ const live = entries.filter((e) => e.value.at >= cutoff)
104
+ for (const e of entries) if (e.value.at < cutoff) await this.state.delete(userID, ARTIFACT_IDEMPOTENCY, e.key, { ifVersion: e.version })
105
+ if (live.length < IDEMPOTENCY_KEYS_MAX) return
106
+ const oldest = live.filter((e) => e.value.state === "done").sort((a, b) => a.value.at - b.value.at)
107
+ for (const e of oldest.slice(0, live.length - IDEMPOTENCY_KEYS_MAX + 1)) await this.state.delete(userID, ARTIFACT_IDEMPOTENCY, e.key, { ifVersion: e.version })
108
+ }
109
+ }