@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.
- package/CHANGELOG.md +430 -1
- package/Dockerfile +7 -2
- package/README.md +230 -32
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/paths.ts +3 -3
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6871 -3460
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
- package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
- package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +96 -14
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +2 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +385 -10
- package/packages/cli/src/messaging.ts +7 -3
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +23 -4
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +460 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +104 -2
- package/packages/node/src/opencode-warm.ts +344 -0
- package/packages/node/src/run.ts +28 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +175 -6
- package/packages/protocol/src/launch.ts +36 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +4 -1
- package/packages/sdk/src/index.ts +6 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +568 -1
- package/packages/sdk/src/wake.ts +20 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +218 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +18 -1
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +125 -2
- package/src/central/agent/delivery.ts +194 -40
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +7 -0
- package/src/central/agent/escalation.ts +10 -2
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +18 -4
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +189 -32
- package/src/central/agent/launch-routes.ts +26 -10
- package/src/central/agent/launch-spec.ts +40 -3
- package/src/central/agent/launch-store.ts +55 -2
- package/src/central/agent/message-limits.ts +89 -11
- package/src/central/agent/message-routes.ts +10 -4
- package/src/central/agent/messages.ts +28 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +89 -19
- package/src/central/agent/routes.ts +178 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent/webhooks.ts +17 -2
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +776 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +350 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +86 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +131 -0
- package/src/central/concierge/proposals.ts +268 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +122 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +125 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +767 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +279 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +28 -6
- package/src/central/http.ts +660 -54
- package/src/central/main.ts +1 -1
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +69 -7
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +786 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +910 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +126 -23
- package/src/migrate.ts +109 -3
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +404 -25
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +65 -10
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +40 -0
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +256 -0
- package/src/spoke/spoke.ts +330 -23
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
- package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
- package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
- package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-lIOfSYck.js +34 -0
- package/web/dist/assets/live-BDq0AABg.js +5 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
- package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
- package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
- package/web/dist/assets/source-CDU04kYw.js +1 -0
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- 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
|
+
}
|