@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.
- package/CHANGELOG.md +229 -0
- package/Dockerfile +7 -2
- package/README.md +153 -24
- 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/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6474 -3470
- 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/enrollment/enroll.ts +20 -8
- package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +95 -13
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +380 -9
- package/packages/cli/src/messaging.ts +3 -1
- package/packages/cli/src/setup/commands.ts +3 -2
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +403 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- 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 +96 -2
- package/packages/node/src/opencode-warm.ts +325 -0
- package/packages/node/src/run.ts +27 -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 +169 -6
- package/packages/protocol/src/launch.ts +22 -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 +3 -1
- package/packages/sdk/src/index.ts +5 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +548 -1
- 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 +215 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +17 -0
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +69 -2
- package/src/central/agent/delivery.ts +95 -15
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +5 -0
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +11 -3
- 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 +16 -2
- package/src/central/agent/launch-routes.ts +6 -1
- package/src/central/agent/launch-spec.ts +14 -1
- package/src/central/agent/message-routes.ts +2 -1
- package/src/central/agent/messages.ts +27 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/read-routes.ts +86 -19
- package/src/central/agent/routes.ts +176 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- 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 +773 -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 +339 -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 +89 -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 +130 -0
- package/src/central/concierge/proposals.ts +262 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +62 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +69 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +740 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +277 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +7 -1
- package/src/central/http.ts +633 -50
- 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 +43 -6
- 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 +772 -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 +888 -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 +97 -15
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +383 -18
- package/src/spoke/listing.ts +47 -4
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +176 -0
- package/src/spoke/spoke.ts +319 -20
- 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-BS0gVkfu.js +1 -0
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
- package/web/dist/assets/Library-CtxRqTjo.js +2 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
- package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/index-DaH5GUdV.js +31 -0
- package/web/dist/assets/index-WWuKfWxL.css +1 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/live-YyTjE47g.js +5 -0
- package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
- package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
- package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
- package/web/dist/assets/source-CvUHTc3O.js +1 -0
- package/web/dist/assets/ui-C9Igxzjm.js +1 -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,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
|
+
}
|