@huynle/hub 0.7.4 → 0.8.1

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 (241) hide show
  1. package/CHANGELOG.md +430 -1
  2. package/Dockerfile +7 -2
  3. package/README.md +230 -32
  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/paths.ts +3 -3
  9. package/packages/claude-code/src/profile.ts +173 -0
  10. package/packages/claude-code/src/sessions.ts +22 -4
  11. package/packages/cli/dist/hub-cli.js +6871 -3460
  12. package/packages/cli/package.json +1 -1
  13. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  14. package/packages/cli/src/artifacts-mcp.ts +160 -0
  15. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  16. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  17. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  18. package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
  19. package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
  20. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  21. package/packages/cli/src/cli.ts +96 -14
  22. package/packages/cli/src/format.ts +48 -10
  23. package/packages/cli/src/http.ts +2 -0
  24. package/packages/cli/src/intents.ts +23 -0
  25. package/packages/cli/src/launches.ts +9 -3
  26. package/packages/cli/src/login.ts +20 -6
  27. package/packages/cli/src/mcp.ts +385 -10
  28. package/packages/cli/src/messaging.ts +7 -3
  29. package/packages/cli/src/profiles-mcp.ts +301 -0
  30. package/packages/cli/src/setup/commands.ts +23 -4
  31. package/packages/cli/src/setup/runtime.ts +3 -2
  32. package/packages/cli/src/spoke-services.ts +1 -1
  33. package/packages/cli/src/team-intake-mcp.ts +158 -0
  34. package/packages/cli/src/teams-mcp.ts +460 -0
  35. package/packages/cli/src/watchlist.ts +135 -0
  36. package/packages/codex/package.json +1 -1
  37. package/packages/codex/src/paths.ts +3 -3
  38. package/packages/codex/src/profile.ts +163 -0
  39. package/packages/codex/src/sessions.ts +12 -0
  40. package/packages/codex/src/spoke.ts +19 -1
  41. package/packages/node/package.json +1 -1
  42. package/packages/node/src/config.ts +104 -2
  43. package/packages/node/src/opencode-warm.ts +344 -0
  44. package/packages/node/src/run.ts +28 -1
  45. package/packages/preset/package.json +1 -1
  46. package/packages/preset/src/profile.ts +34 -0
  47. package/packages/preset/src/spoke.ts +4 -0
  48. package/packages/protocol/package.json +1 -1
  49. package/packages/protocol/src/artifacts.ts +142 -0
  50. package/packages/protocol/src/concierge.ts +366 -0
  51. package/packages/protocol/src/contracts.ts +104 -0
  52. package/packages/protocol/src/graph.ts +70 -0
  53. package/packages/protocol/src/hub-workspace.ts +201 -0
  54. package/packages/protocol/src/index.ts +175 -6
  55. package/packages/protocol/src/launch.ts +36 -0
  56. package/packages/protocol/src/profiles.ts +301 -0
  57. package/packages/protocol/src/teams.ts +204 -0
  58. package/packages/runtime-openshell/package.json +1 -1
  59. package/packages/runtime-sdk/package.json +1 -1
  60. package/packages/sdk/package.json +1 -1
  61. package/packages/sdk/src/address.ts +50 -2
  62. package/packages/sdk/src/artifact-jobs.ts +194 -0
  63. package/packages/sdk/src/artifacts.ts +187 -0
  64. package/packages/sdk/src/client.ts +226 -5
  65. package/packages/sdk/src/concierge.ts +88 -0
  66. package/packages/sdk/src/errors.ts +4 -1
  67. package/packages/sdk/src/index.ts +6 -0
  68. package/packages/sdk/src/team-intake.ts +122 -0
  69. package/packages/sdk/src/teams.ts +346 -0
  70. package/packages/sdk/src/types.ts +568 -1
  71. package/packages/sdk/src/wake.ts +20 -0
  72. package/packages/spoke-sdk/package.json +1 -1
  73. package/packages/spoke-sdk/src/index.ts +1 -0
  74. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  75. package/src/central/actions.ts +7 -0
  76. package/src/central/agent/acting-routes.ts +2 -1
  77. package/src/central/agent/acting-switch.ts +2 -1
  78. package/src/central/agent/acting.ts +218 -10
  79. package/src/central/agent/app-tokens.ts +3 -2
  80. package/src/central/agent/audit.ts +18 -1
  81. package/src/central/agent/body.ts +163 -0
  82. package/src/central/agent/decisions.ts +125 -2
  83. package/src/central/agent/delivery.ts +194 -40
  84. package/src/central/agent/directory-routes.ts +37 -7
  85. package/src/central/agent/directory.ts +70 -14
  86. package/src/central/agent/errors.ts +7 -0
  87. package/src/central/agent/escalation.ts +10 -2
  88. package/src/central/agent/events-routes.ts +21 -4
  89. package/src/central/agent/events.ts +18 -4
  90. package/src/central/agent/form-check.ts +85 -0
  91. package/src/central/agent/grant-requests.ts +2 -1
  92. package/src/central/agent/grant-routes.ts +4 -3
  93. package/src/central/agent/grants.ts +29 -2
  94. package/src/central/agent/idempotency.ts +133 -0
  95. package/src/central/agent/intent-routes.ts +116 -0
  96. package/src/central/agent/intents.ts +505 -0
  97. package/src/central/agent/interaction-graph-routes.ts +212 -0
  98. package/src/central/agent/interaction-graph.ts +865 -0
  99. package/src/central/agent/launch-engine.ts +189 -32
  100. package/src/central/agent/launch-routes.ts +26 -10
  101. package/src/central/agent/launch-spec.ts +40 -3
  102. package/src/central/agent/launch-store.ts +55 -2
  103. package/src/central/agent/message-limits.ts +89 -11
  104. package/src/central/agent/message-routes.ts +10 -4
  105. package/src/central/agent/messages.ts +28 -2
  106. package/src/central/agent/names.ts +12 -2
  107. package/src/central/agent/placement.ts +93 -18
  108. package/src/central/agent/read-routes.ts +89 -19
  109. package/src/central/agent/routes.ts +178 -5
  110. package/src/central/agent/webhook-routes.ts +2 -1
  111. package/src/central/agent/webhooks.ts +17 -2
  112. package/src/central/agent-data.ts +4 -0
  113. package/src/central/artifacts/access.ts +44 -0
  114. package/src/central/artifacts/blob-store.ts +113 -0
  115. package/src/central/artifacts/blob-support.ts +98 -0
  116. package/src/central/artifacts/events.ts +27 -0
  117. package/src/central/artifacts/host-caps.ts +162 -0
  118. package/src/central/artifacts/host.ts +315 -0
  119. package/src/central/artifacts/idempotency.ts +109 -0
  120. package/src/central/artifacts/jobs-routes.ts +776 -0
  121. package/src/central/artifacts/jobs.ts +344 -0
  122. package/src/central/artifacts/maintenance.ts +103 -0
  123. package/src/central/artifacts/routes.ts +360 -0
  124. package/src/central/artifacts/store.ts +776 -0
  125. package/src/central/artifacts/workspaces.ts +31 -0
  126. package/src/central/backend/file-blobs.ts +377 -0
  127. package/src/central/backend/index.ts +12 -3
  128. package/src/central/backend/memory-blobs.ts +116 -0
  129. package/src/central/backend/postgres-blobs.ts +513 -0
  130. package/src/central/backend/postgres-migrate.ts +33 -0
  131. package/src/central/backend/postgres-state.ts +19 -0
  132. package/src/central/backend/types.ts +8 -0
  133. package/src/central/concierge/activity.ts +142 -0
  134. package/src/central/concierge/arm.ts +63 -0
  135. package/src/central/concierge/board-render.ts +190 -0
  136. package/src/central/concierge/board-results.ts +169 -0
  137. package/src/central/concierge/board-routes.ts +350 -0
  138. package/src/central/concierge/board.ts +249 -0
  139. package/src/central/concierge/context.ts +221 -0
  140. package/src/central/concierge/entitlements.ts +13 -0
  141. package/src/central/concierge/guard.ts +120 -0
  142. package/src/central/concierge/inbound.ts +86 -0
  143. package/src/central/concierge/index.ts +84 -0
  144. package/src/central/concierge/notes.ts +41 -0
  145. package/src/central/concierge/notifier.ts +986 -0
  146. package/src/central/concierge/prompt.ts +131 -0
  147. package/src/central/concierge/proposals.ts +268 -0
  148. package/src/central/concierge/push-remap.ts +49 -0
  149. package/src/central/concierge/routes.ts +122 -0
  150. package/src/central/concierge/schedule.ts +113 -0
  151. package/src/central/concierge/service.ts +243 -0
  152. package/src/central/concierge/session-routes.ts +125 -0
  153. package/src/central/concierge/settings.ts +217 -0
  154. package/src/central/concierge/supervisor.ts +767 -0
  155. package/src/central/concierge/tiers.ts +260 -0
  156. package/src/central/concierge/watchlist-routes.ts +279 -0
  157. package/src/central/concierge/watchlist.ts +273 -0
  158. package/src/central/config.ts +28 -6
  159. package/src/central/http.ts +660 -54
  160. package/src/central/main.ts +1 -1
  161. package/src/central/mcp-http.ts +10 -21
  162. package/src/central/profiles/apply.ts +333 -0
  163. package/src/central/profiles/content.ts +174 -0
  164. package/src/central/profiles/events.ts +40 -0
  165. package/src/central/profiles/idempotency.ts +14 -0
  166. package/src/central/profiles/import.ts +139 -0
  167. package/src/central/profiles/routes.ts +356 -0
  168. package/src/central/profiles/store.ts +597 -0
  169. package/src/central/profiles/usage-ports.ts +120 -0
  170. package/src/central/profiles/usage.ts +364 -0
  171. package/src/central/push.ts +7 -0
  172. package/src/central/registry.ts +69 -7
  173. package/src/central/store.ts +4 -0
  174. package/src/central/teams/collections.ts +34 -0
  175. package/src/central/teams/discovery.ts +346 -0
  176. package/src/central/teams/draft-edits.ts +46 -0
  177. package/src/central/teams/events.ts +46 -0
  178. package/src/central/teams/intake-routes.ts +100 -0
  179. package/src/central/teams/intake.ts +786 -0
  180. package/src/central/teams/policy.ts +231 -0
  181. package/src/central/teams/routes.ts +516 -0
  182. package/src/central/teams/seats.ts +272 -0
  183. package/src/central/teams/staffing.ts +910 -0
  184. package/src/central/teams/store.ts +667 -0
  185. package/src/central/teams/validation.ts +256 -0
  186. package/src/central/workspaces/board.ts +408 -0
  187. package/src/central/workspaces/checkpoints.ts +316 -0
  188. package/src/central/workspaces/coordinator.ts +841 -0
  189. package/src/central/workspaces/events.ts +85 -0
  190. package/src/central/workspaces/lifecycle.ts +93 -0
  191. package/src/central/workspaces/master.ts +490 -0
  192. package/src/central/workspaces/members.ts +632 -0
  193. package/src/central/workspaces/routes.ts +354 -0
  194. package/src/central/workspaces/sidechats-routes.ts +410 -0
  195. package/src/central/workspaces/sidechats.ts +482 -0
  196. package/src/central/workspaces/store.ts +571 -0
  197. package/src/central/workspaces/supervision.ts +355 -0
  198. package/src/index.ts +126 -23
  199. package/src/migrate.ts +109 -3
  200. package/src/spoke/concierge-agent.ts +33 -0
  201. package/src/spoke/deliver.ts +22 -4
  202. package/src/spoke/env-inject.ts +8 -1
  203. package/src/spoke/fork-revert.ts +142 -0
  204. package/src/spoke/hub-rpc.ts +41 -5
  205. package/src/spoke/hub-tool.ts +404 -25
  206. package/src/spoke/launchers.ts +6 -4
  207. package/src/spoke/listing.ts +65 -10
  208. package/src/spoke/localhub.ts +14 -4
  209. package/src/spoke/locations.ts +40 -0
  210. package/src/spoke/profile-apply.ts +298 -0
  211. package/src/spoke/session-context.ts +256 -0
  212. package/src/spoke/spoke.ts +330 -23
  213. package/src/spoke/timeline.ts +6 -1
  214. package/src/spoke/tracker.ts +23 -1
  215. package/src/spoke/who-teams.ts +81 -0
  216. package/src/tui-messaging.ts +32 -18
  217. package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
  218. package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
  219. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  220. package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
  221. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  222. package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
  223. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
  224. package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
  225. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  226. package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
  227. package/web/dist/assets/index-BabrNDB3.css +1 -0
  228. package/web/dist/assets/index-lIOfSYck.js +34 -0
  229. package/web/dist/assets/live-BDq0AABg.js +5 -0
  230. package/web/dist/assets/live-BUofsQpz.css +1 -0
  231. package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
  232. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  233. package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
  234. package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
  235. package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
  236. package/web/dist/assets/source-CDU04kYw.js +1 -0
  237. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  238. package/web/dist/index.html +5 -2
  239. package/web/dist/sw.js +22 -2
  240. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  241. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -0,0 +1,360 @@
1
+ // The artifact transfer API (HWA-21; contract docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md, Artifacts),
2
+ // under /api/agent with the agent error envelope:
3
+ //
4
+ // GET /artifacts the caller's library (limit, cursor, state, workspace)
5
+ // POST /artifacts create with the first revision (Idempotency-Key)
6
+ // POST /artifacts/blobs stage raw bytes; answers a BlobRef to reference
7
+ // GET /artifacts/:id one artifact
8
+ // PATCH /artifacts/:id title, description, tags, metadata {expectedRevision}
9
+ // DELETE /artifacts/:id irreversible {expectedRevision}
10
+ // PUT /artifacts/:id/visibility {expectedRevision, visibility, workspaceID?}
11
+ // POST /artifacts/:id/archive | /unarchive {expectedRevision}
12
+ // GET /artifacts/:id/revisions newest first (limit, cursor)
13
+ // POST /artifacts/:id/revisions {baseRevisionID, blobs|upload, entry?} (Idempotency-Key)
14
+ // GET /artifacts/:id/revisions/:rev one revision
15
+ // GET /artifacts/:id/revisions/:rev/blobs/:hash the bytes, as an attachment
16
+ //
17
+ // Files of a revision come two ways: `upload` carries small files inline in the JSON body (utf8 or base64, at most
18
+ // ARTIFACT_UPLOAD_LIMITS.inlineBytes decoded in total), and `blobs` references bytes this user already stored:
19
+ // staged with POST /artifacts/blobs (a raw, streamed body up to ARTIFACT_LIMITS.blobBytes) or unchanged files of an
20
+ // earlier revision. So SDK and MCP callers pass references, never huge payloads. A staged blob nobody references is
21
+ // collected by recovery once untouched for ARTIFACT_GRACE_MS (`referenceWithinMs` in the answer).
22
+ //
23
+ // Media types are the client's declaration, checked against ARTIFACT_MEDIA_TYPES and never sniffed. Downloads are
24
+ // never rendered by the hub: Content-Disposition attachment, nosniff, a sandboxing CSP and no caching. Interactive
25
+ // html/app content renders only in the isolated artifact host (HWA-26); src/central/assets.ts never serves it.
26
+ //
27
+ // Who: users (cookie, device token) everything; app tokens by scope (artifacts:read, artifacts:write); sessions may
28
+ // read (the store admits workspace-visible artifacts of workspaces they currently belong to), create and stage
29
+ // bytes, but never revise or manage (v1 write policy, enforced by the store's access policy). Another user's
30
+ // records answer not_found. A hub without artifact storage (cluster mode before HWA-22) answers 422 unsupported.
31
+ import { bodyError, boundedText, readBounded } from "../agent/body"
32
+ import { ARTIFACT_LIMITS, ARTIFACT_MEDIA_TYPES, type ArtifactVisibility, type BlobRef, isBlobHash, isContractID, type RecordState } from "../../protocol"
33
+ import type { AgentActor } from "../agent/actor"
34
+ import { AgentError } from "../agent/errors"
35
+ // Types only: ../agent/routes imports this module (a value import would be circular).
36
+ import type { AgentContext, AgentRoute } from "../agent/routes"
37
+ import type { Clock, StateStore } from "../backend/types"
38
+ import type { ArtifactPrincipal } from "./access"
39
+ import { type BlobBody, BlobHashMismatchError, BlobInputError, BlobQuotaError, type BlobStore, BlobTooLargeError } from "./blob-store"
40
+ import { ArtifactIdempotency } from "./idempotency"
41
+ import { ARTIFACT_GRACE_MS, type ArtifactInput, type ArtifactStore, type RevisionFile, type RevisionInput } from "./store"
42
+
43
+ export const ARTIFACT_UPLOAD_LIMITS = {
44
+ /** Decoded bytes of all inline (`upload`) files of one request. */
45
+ inlineBytes: 1024 * 1024,
46
+ /** A JSON request body. */
47
+ jsonBytes: 2 * 1024 * 1024,
48
+ /** A staged blob (the BlobStore's ceiling). */
49
+ blobBytes: ARTIFACT_LIMITS.blobBytes,
50
+ /** Staged uploads one user may stream at once (bounds temp disk use). */
51
+ uploadsInFlight: 4,
52
+ } as const
53
+
54
+ export interface ArtifactRouteDeps {
55
+ /** Absent: this hub keeps no artifacts (every route answers 422 unsupported). */
56
+ readonly artifacts?: ArtifactStore
57
+ /** The store's BlobStore (staged uploads write to it). */
58
+ readonly blobs?: BlobStore
59
+ /** Where Idempotency-Keys live. */
60
+ readonly state?: StateStore
61
+ readonly now?: Clock
62
+ /** Quota of staged uploads (default ARTIFACT_LIMITS.userBlobBytes; the store applies its own on publish). */
63
+ readonly userBlobBytes?: number
64
+ }
65
+
66
+ const P = "/api/agent/artifacts"
67
+ const NO_STORE = { "cache-control": "no-store" }
68
+ const READ = { session: true, scope: "artifacts:read" } as const
69
+ const WRITE = { session: true, scope: "artifacts:write" } as const
70
+ const MANAGE = { scope: "artifacts:write" } as const
71
+ const CREATE_KEYS = new Set(["title", "kind", "description", "visibility", "workspaceID", "tags", "metadata", "entry", "blobs", "upload"])
72
+ const REVISE_KEYS = new Set(["baseRevisionID", "entry", "blobs", "upload"])
73
+ const FILE_KEYS = new Set(["path", "mediaType", "data", "encoding", "hash"])
74
+ const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
75
+ const MEDIA_EXT: Readonly<Record<string, string>> = {
76
+ "text/markdown": ".md",
77
+ "text/plain": ".txt",
78
+ "text/csv": ".csv",
79
+ "text/html": ".html",
80
+ "text/css": ".css",
81
+ "text/javascript": ".js",
82
+ "application/json": ".json",
83
+ "application/pdf": ".pdf",
84
+ "image/png": ".png",
85
+ "image/jpeg": ".jpg",
86
+ "image/gif": ".gif",
87
+ "image/webp": ".webp",
88
+ "image/svg+xml": ".svg",
89
+ }
90
+
91
+ const invalid = (message: string) => new AgentError("invalid", message)
92
+ const unsupported = () => new AgentError("unsupported", "This hub does not store artifacts", { extra: { feature: "artifacts" } })
93
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
94
+
95
+ const principalOf = (actor: AgentActor): ArtifactPrincipal => (actor.kind === "session" ? { kind: "session", sessionID: actor.sessionID } : { kind: "user" })
96
+
97
+ /** Reads a JSON object body of at most `max` bytes (declared or streamed; agent/body.ts). */
98
+ async function readJson(request: Request, max: number): Promise<Record<string, unknown>> {
99
+ const text = await boundedText(request, max)
100
+ let body: unknown
101
+ try {
102
+ body = JSON.parse(text)
103
+ } catch {
104
+ throw invalid("Invalid JSON")
105
+ }
106
+ if (!isObj(body)) throw invalid("Expected a JSON object")
107
+ return body
108
+ }
109
+
110
+ function onlyKeys(body: Record<string, unknown>, allowed: ReadonlySet<string>, what: string): void {
111
+ const extra = Object.keys(body).find((k) => !allowed.has(k))
112
+ if (extra !== undefined) throw invalid(`${what} has an unknown field ${JSON.stringify(extra.slice(0, 50))}`)
113
+ }
114
+
115
+ /** The files of a create/revise body: `blobs` references and decoded inline `upload` files (bounded). */
116
+ function revisionInput(body: Record<string, unknown>): RevisionInput {
117
+ const files: RevisionFile[] = []
118
+ if (body.blobs !== undefined) {
119
+ if (!Array.isArray(body.blobs)) throw invalid("blobs must be a list of blob references")
120
+ for (const ref of body.blobs) {
121
+ if (!isObj(ref)) throw invalid("each blob must be {hash, bytes, mediaType, path?}")
122
+ const { hash, bytes, mediaType, path } = ref
123
+ files.push({ ref: { hash, bytes, mediaType, ...(path !== undefined ? { path } : {}) } as BlobRef })
124
+ }
125
+ }
126
+ if (body.upload !== undefined) {
127
+ if (!Array.isArray(body.upload)) throw invalid("upload must be a list of files")
128
+ let total = 0
129
+ for (const file of body.upload) {
130
+ if (!isObj(file)) throw invalid("each upload must be {mediaType, data, encoding?, path?, hash?}")
131
+ onlyKeys(file, FILE_KEYS, "an upload")
132
+ const { path, mediaType, data, encoding = "utf8", hash } = file
133
+ if (typeof mediaType !== "string") throw invalid("upload mediaType must be a string")
134
+ if (typeof data !== "string") throw invalid("upload data must be a string")
135
+ if (hash !== undefined && !isBlobHash(hash)) throw invalid("upload hash must be sha256:<64 hex>")
136
+ if (path !== undefined && typeof path !== "string") throw invalid("upload path must be a string")
137
+ let bytes: Uint8Array
138
+ if (encoding === "utf8") bytes = Buffer.from(data, "utf8")
139
+ else if (encoding === "base64") {
140
+ if (!BASE64.test(data)) throw invalid("upload data is not valid base64")
141
+ bytes = Buffer.from(data, "base64")
142
+ } else throw invalid("upload encoding must be utf8 or base64")
143
+ total += bytes.byteLength
144
+ if (total > ARTIFACT_UPLOAD_LIMITS.inlineBytes) {
145
+ throw invalid(`Inline uploads are limited to ${ARTIFACT_UPLOAD_LIMITS.inlineBytes} bytes per request; stage bigger files with POST ${P}/blobs`)
146
+ }
147
+ files.push({ body: bytes, mediaType, ...(path !== undefined ? { path } : {}), ...(hash !== undefined ? { expectedHash: hash } : {}) })
148
+ }
149
+ }
150
+ if (body.entry !== undefined && typeof body.entry !== "string") throw invalid("entry must be a path")
151
+ return { files, ...(body.entry !== undefined ? { entry: body.entry as string } : {}) }
152
+ }
153
+
154
+ /** A Content-Type's essence (`type/subtype`, lowercase, parameters dropped): the client's declaration, never sniffed. */
155
+ function declaredMediaType(header: string | null): string {
156
+ const essence = (header ?? "").split(";")[0]!.trim().toLowerCase()
157
+ if (!(ARTIFACT_MEDIA_TYPES as readonly string[]).includes(essence)) {
158
+ throw new AgentError("invalid", "Content-Type must be an allowed artifact media type", { extra: { mediaTypes: ARTIFACT_MEDIA_TYPES } })
159
+ }
160
+ return essence
161
+ }
162
+
163
+ function mapBlobError(error: unknown): unknown {
164
+ if (error instanceof BlobQuotaError) return new AgentError("rate_limited", `Blob storage limit of ${error.quotaBytes} reached`, { extra: { limit: error.quotaBytes } })
165
+ if (error instanceof BlobTooLargeError || error instanceof BlobHashMismatchError || error instanceof BlobInputError) return invalid(error.message)
166
+ return error
167
+ }
168
+
169
+ function intParam(url: URL, key: string): number | undefined {
170
+ const raw = url.searchParams.get(key)
171
+ if (raw === null || raw === "") return undefined
172
+ if (!/^\d{1,6}$/.test(raw)) throw invalid(`${key} must be a whole number`)
173
+ return Number(raw)
174
+ }
175
+
176
+ function expectedRevision(body: Record<string, unknown>): number {
177
+ const value = body.expectedRevision
178
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw invalid("expectedRevision must be the revision you read")
179
+ return value
180
+ }
181
+
182
+ /** A download's file name: the bundle path's last segment (a restricted charset), else the hash and an extension. */
183
+ function fileName(ref: BlobRef): string {
184
+ const base = ref.path?.split("/").pop()
185
+ return base && /^[A-Za-z0-9._-]{1,200}$/.test(base) ? base : `${ref.hash.slice("sha256:".length, "sha256:".length + 16)}${MEDIA_EXT[ref.mediaType] ?? ""}`
186
+ }
187
+
188
+ export function artifactRoutes(deps: ArtifactRouteDeps): AgentRoute[] {
189
+ const idempotency = deps.state ? new ArtifactIdempotency(deps.state, deps.now) : undefined
190
+ const inFlight = new Map<string, number>()
191
+ const store = (): ArtifactStore => {
192
+ if (!deps.artifacts) throw unsupported()
193
+ return deps.artifacts
194
+ }
195
+ const blobStore = (): BlobStore => {
196
+ if (!deps.artifacts || !deps.blobs) throw unsupported()
197
+ return deps.blobs
198
+ }
199
+ const json = (body: unknown, status = 200) => Response.json(body, { status, headers: NO_STORE })
200
+
201
+ const route = (method: string, path: string, allow: AgentRoute["allow"], handle: (c: AgentContext) => Promise<Response>): AgentRoute => ({ method, path, allow, handle })
202
+
203
+ /** Runs an idempotent create; `route` scopes the key. */
204
+ const idempotent = async (c: AgentContext, scope: string, body: unknown, work: () => Promise<{ artifact: { id: string }; revision: { id: string } }>) => {
205
+ const key = c.request.headers.get("idempotency-key")
206
+ // Keys are per principal as well as per user and route, so a replay answers only the caller that created the
207
+ // result (a session may not otherwise read what it created): the replay reads as the owner.
208
+ const principal = principalOf(c.actor)
209
+ const keyScope = `${scope}|${principal.kind === "session" ? principal.sessionID : "user"}`
210
+ const replay = async (ids: { artifactID: string; revisionID: string }) => {
211
+ const owner: ArtifactPrincipal = { kind: "user" }
212
+ return { artifact: await store().get(c.user.id, owner, ids.artifactID), revision: await store().getRevision(c.user.id, owner, ids.artifactID, ids.revisionID) }
213
+ }
214
+ const run = async () => {
215
+ const value = await work()
216
+ return { ids: { artifactID: value.artifact.id, revisionID: value.revision.id }, value }
217
+ }
218
+ if (key !== null && !idempotency) throw unsupported()
219
+ const result = idempotency ? await idempotency.run(c.user.id, keyScope, key, body, run, replay) : { value: (await run()).value, created: true }
220
+ return json(result.value, result.created ? 201 : 200)
221
+ }
222
+
223
+ return [
224
+ route("GET", P, READ, async ({ user, actor, url }) => {
225
+ const state = url.searchParams.get("state") ?? undefined
226
+ if (state !== undefined && state !== "active" && state !== "archived") throw invalid("state must be active or archived")
227
+ const limit = intParam(url, "limit")
228
+ const cursor = url.searchParams.get("cursor") ?? undefined
229
+ const workspaceID = url.searchParams.get("workspace") ?? undefined
230
+ const page = await store().list(user.id, principalOf(actor), {
231
+ ...(limit !== undefined ? { limit } : {}),
232
+ ...(cursor ? { cursor } : {}),
233
+ ...(state ? { state: state as RecordState } : {}),
234
+ ...(workspaceID ? { workspaceID } : {}),
235
+ })
236
+ return json(page)
237
+ }),
238
+
239
+ route("POST", P, WRITE, async (c) => {
240
+ store()
241
+ const body = await readJson(c.request, ARTIFACT_UPLOAD_LIMITS.jsonBytes)
242
+ onlyKeys(body, CREATE_KEYS, "The body")
243
+ const files = revisionInput(body)
244
+ const { blobs: _b, upload: _u, entry: _e, ...meta } = body
245
+ return idempotent(c, "create", body, () => store().create(c.user.id, principalOf(c.actor), meta as unknown as ArtifactInput, files))
246
+ }),
247
+
248
+ route("POST", `${P}/blobs`, WRITE, async ({ user, request }) => {
249
+ const blobs = blobStore()
250
+ const mediaType = declaredMediaType(request.headers.get("content-type"))
251
+ const expectedHash = request.headers.get("x-blob-hash") ?? undefined
252
+ if (expectedHash !== undefined && !isBlobHash(expectedHash)) throw invalid("X-Blob-Hash must be sha256:<64 hex>")
253
+ const running = inFlight.get(user.id) ?? 0
254
+ if (running >= ARTIFACT_UPLOAD_LIMITS.uploadsInFlight) {
255
+ throw new AgentError("rate_limited", `At most ${ARTIFACT_UPLOAD_LIMITS.uploadsInFlight} uploads at once`, { extra: { limit: ARTIFACT_UPLOAD_LIMITS.uploadsInFlight, retryAfter: 1 } })
256
+ }
257
+ inFlight.set(user.id, running + 1)
258
+ try {
259
+ // The whole body is read first, continuously, into memory bounded by the blob limit (agent/body.ts: bytes,
260
+ // rate and idle budgets; refused past the limit, with Connection: close when the rest is left on the
261
+ // socket), and only then handed to the store. Awaiting a disk write per chunk let Bun gather everything that
262
+ // arrived meanwhile into one chunk: an endless full-speed body grew the hub by gigabytes (HWA-28).
263
+ const max = ARTIFACT_UPLOAD_LIMITS.blobBytes
264
+ const read = await readBounded(request, max)
265
+ if (!read.ok) {
266
+ const refused = bodyError(read, max)
267
+ throw read.reason === "too_large" ? new AgentError("invalid", `A blob is at most ${max} bytes`, { ...refused.options }) : refused
268
+ }
269
+ const blob = await blobs
270
+ .put(user.id, read.bytes, mediaType, {
271
+ maxBytes: max,
272
+ quotaBytes: deps.userBlobBytes ?? ARTIFACT_LIMITS.userBlobBytes,
273
+ ...(expectedHash !== undefined ? { expectedHash } : {}),
274
+ })
275
+ .catch((error: unknown) => {
276
+ throw mapBlobError(error)
277
+ })
278
+ return json({ blob, referenceWithinMs: ARTIFACT_GRACE_MS }, 201)
279
+ } finally {
280
+ const left = (inFlight.get(user.id) ?? 1) - 1
281
+ if (left > 0) inFlight.set(user.id, left)
282
+ else inFlight.delete(user.id)
283
+ }
284
+ }),
285
+
286
+ route("GET", `${P}/:id`, READ, async ({ user, actor, params }) => json({ artifact: await store().get(user.id, principalOf(actor), params.id!) })),
287
+
288
+ route("PATCH", `${P}/:id`, MANAGE, async ({ user, actor, params, request }) => {
289
+ const body = await readJson(request, ARTIFACT_UPLOAD_LIMITS.jsonBytes)
290
+ onlyKeys(body, new Set(["expectedRevision", "title", "description", "tags", "metadata"]), "The body")
291
+ const artifact = await store().update(user.id, principalOf(actor), params.id!, { ...(body as object), expectedRevision: expectedRevision(body) } as Parameters<ArtifactStore["update"]>[3])
292
+ return json({ artifact })
293
+ }),
294
+
295
+ route("DELETE", `${P}/:id`, MANAGE, async ({ user, actor, params, request }) => {
296
+ const body = await readJson(request, 4096)
297
+ await store().delete(user.id, principalOf(actor), params.id!, expectedRevision(body))
298
+ return new Response(null, { status: 204, headers: NO_STORE })
299
+ }),
300
+
301
+ route("PUT", `${P}/:id/visibility`, MANAGE, async ({ user, actor, params, request }) => {
302
+ const body = await readJson(request, 4096)
303
+ onlyKeys(body, new Set(["expectedRevision", "visibility", "workspaceID"]), "The body")
304
+ if (body.workspaceID !== undefined && typeof body.workspaceID !== "string") throw invalid("workspaceID must be a workspace ID")
305
+ const artifact = await store().setVisibility(user.id, principalOf(actor), params.id!, {
306
+ expectedRevision: expectedRevision(body),
307
+ visibility: body.visibility as ArtifactVisibility,
308
+ ...(body.workspaceID !== undefined ? { workspaceID: body.workspaceID as string } : {}),
309
+ })
310
+ return json({ artifact })
311
+ }),
312
+
313
+ ...(["archive", "unarchive"] as const).map((verb) =>
314
+ route("POST", `${P}/:id/${verb}`, MANAGE, async ({ user, actor, params, request }) => {
315
+ const body = await readJson(request, 4096)
316
+ const artifact = await store()[verb](user.id, principalOf(actor), params.id!, expectedRevision(body))
317
+ return json({ artifact })
318
+ }),
319
+ ),
320
+
321
+ route("GET", `${P}/:id/revisions`, READ, async ({ user, actor, params, url }) => {
322
+ const limit = intParam(url, "limit")
323
+ const cursor = url.searchParams.get("cursor") ?? undefined
324
+ return json(await store().listRevisions(user.id, principalOf(actor), params.id!, { ...(limit !== undefined ? { limit } : {}), ...(cursor ? { cursor } : {}) }))
325
+ }),
326
+
327
+ route("POST", `${P}/:id/revisions`, WRITE, async (c) => {
328
+ store()
329
+ const body = await readJson(c.request, ARTIFACT_UPLOAD_LIMITS.jsonBytes)
330
+ onlyKeys(body, REVISE_KEYS, "The body")
331
+ const id = c.params.id!
332
+ if (!isContractID("artifact", id)) throw new AgentError("not_found", "No such artifact")
333
+ if (typeof body.baseRevisionID !== "string") throw invalid("baseRevisionID is required (the latest revision you read)")
334
+ const files = revisionInput(body)
335
+ return idempotent(c, `revise:${id}`, body, () => store().revise(c.user.id, principalOf(c.actor), id, { ...files, baseRevisionID: body.baseRevisionID as string }))
336
+ }),
337
+
338
+ route("GET", `${P}/:id/revisions/:rev`, READ, async ({ user, actor, params }) => json({ revision: await store().getRevision(user.id, principalOf(actor), params.id!, params.rev!) })),
339
+
340
+ route("GET", `${P}/:id/revisions/:rev/blobs/:hash`, READ, async ({ user, actor, params }) => {
341
+ const hash = params.hash!
342
+ if (!isBlobHash(hash)) throw new AgentError("not_found", "No such blob in this revision")
343
+ const { ref, blob } = await store().readBlob(user.id, principalOf(actor), params.id!, params.rev!, hash)
344
+ return new Response(blob.body, {
345
+ status: 200,
346
+ headers: {
347
+ "content-type": ref.mediaType,
348
+ "content-length": String(blob.bytes),
349
+ "content-disposition": `attachment; filename="${fileName(ref)}"`,
350
+ "x-content-type-options": "nosniff",
351
+ "content-security-policy": "default-src 'none'; sandbox",
352
+ "cross-origin-resource-policy": "same-origin",
353
+ "referrer-policy": "no-referrer",
354
+ "cache-control": "private, no-store",
355
+ etag: `"${ref.hash}"`,
356
+ },
357
+ })
358
+ }),
359
+ ]
360
+ }