@prismnetwork/agent-sdk 0.2.0 → 0.3.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/README.md CHANGED
@@ -17,7 +17,7 @@ import { PrismAgent, DEFAULT_IMAGE } from "@prismnetwork/agent-sdk";
17
17
 
18
18
  const agent = new PrismAgent({
19
19
  privateKey: process.env.AGENT_KEY, // agent's wallet
20
- escrow: "0x71Df0eF3bc81022cB3bec0b1a05f52f12bAfcDeD",
20
+ escrow: "0x62C042265991bEa17B07229322A01850974626dA",
21
21
  });
22
22
 
23
23
  await agent.authenticate();
@@ -29,6 +29,42 @@ agent.endLease(lease);
29
29
 
30
30
  `image` must be an immutable digest-pinned reference (`repo@sha256:...`). `DEFAULT_IMAGE` is one; a plain tag is rejected.
31
31
 
32
+ ## Vault
33
+
34
+ Cards, identity documents, API credentials and recovery codes go in the vault
35
+ rather than on a leased box. Items are sealed here, on your machine, under a
36
+ key derived from a wallet signature that is never transmitted, so Prism stores
37
+ ciphertext and holds no way to read it.
38
+
39
+ ```js
40
+ await agent.vault.unlock();
41
+
42
+ const card = await agent.vault.put({ pan: "4111111111111111" }, { label: "billing" });
43
+ const value = await agent.vault.get(card.item_id, { json: true });
44
+ ```
45
+
46
+ `unlock()` derives the key from a signature over a fixed statement. Ethereum's
47
+ ECDSA is deterministic, so the same wallet reproduces the same vault on any
48
+ machine — no recovery copy is held anywhere. Pass `{ passphrase }` to require a
49
+ second factor beyond the wallet.
50
+
51
+ Every item carries the weakest workspace trust class it may ever be released
52
+ into. New items default to `confidential`, which is above anything the network
53
+ serves today, so `releaseInto` refuses rather than exposing a secret to a host
54
+ that can read it:
55
+
56
+ ```js
57
+ await agent.vault.releaseInto(lease, card.item_id, { json: true }); // throws on open capacity
58
+ ```
59
+
60
+ Lowering an item's floor is deliberate and reseals the item. Allowed releases
61
+ are recorded and readable with `agent.vault.releases()`.
62
+
63
+ The account, item slot, version and trust floor are authenticated into the
64
+ ciphertext, so a service that moved an item between accounts, replayed an older
65
+ version, or lowered its floor would produce a failed decrypt rather than a
66
+ plausible wrong answer. See [docs/VAULT.md](../docs/VAULT.md).
67
+
32
68
  ## Auth
33
69
 
34
70
  `authenticate()` fetches a challenge (`GET /api/agent/challenge`), signs the message with the wallet, and exchanges it for a session (`POST /api/agent/session`). The session is a bearer token used on every `/api/agent/proxy/*` call. No shared secret, no cookie. The wallet is the identity (`subject = wallet:0x...`).
package/package.json CHANGED
@@ -1,21 +1,27 @@
1
1
  {
2
2
  "name": "@prismnetwork/agent-sdk",
3
- "version": "0.2.0",
4
- "description": "Headless GPU leasing on Prism Network for wallet-holding agents.",
3
+ "version": "0.3.1",
4
+ "description": "Headless GPU leasing and renter-encrypted storage on Prism Network for wallet-holding agents.",
5
5
  "type": "module",
6
6
  "main": "prism.mjs",
7
7
  "exports": {
8
- ".": "./prism.mjs"
8
+ ".": "./prism.mjs",
9
+ "./vault": {
10
+ "types": "./vault.d.ts",
11
+ "default": "./vault.mjs"
12
+ }
9
13
  },
10
14
  "files": [
11
15
  "prism.mjs",
16
+ "vault.mjs",
17
+ "vault.d.ts",
12
18
  "README.md"
13
19
  ],
14
20
  "engines": {
15
21
  "node": ">=20"
16
22
  },
17
23
  "peerDependencies": {
18
- "viem": "^2"
24
+ "viem": "^2.55.11"
19
25
  },
20
26
  "keywords": [
21
27
  "prism",
package/prism.mjs CHANGED
@@ -14,6 +14,9 @@ import {
14
14
  stringToBytes,
15
15
  } from "viem";
16
16
  import { privateKeyToAccount } from "viem/accounts";
17
+ import { PrismVault } from "./vault.mjs";
18
+
19
+ export { PrismVault, VaultError, DEFAULT_TRUST_FLOOR, VAULT_KEY_STATEMENT } from "./vault.mjs";
17
20
 
18
21
  export const robinhoodChain = defineChain({
19
22
  id: 4663,
@@ -46,6 +49,20 @@ const escrowAbi = parseAbi([
46
49
  "function createLease(bytes32 nodeId, uint32 duration, bytes32 clientReference) returns (uint256)",
47
50
  ]);
48
51
 
52
+ // Matches the limit the control plane and the node both enforce, so a command
53
+ // that cannot run is rejected here rather than after an escrow is funded.
54
+ const MAX_COMMAND_BYTES = 8 * 1024;
55
+
56
+ function assertCommand(value) {
57
+ if (typeof value !== "string" || value.trim() === "") {
58
+ throw new PrismError(400, "invalid_command", { hint: "a batch command cannot be empty" });
59
+ }
60
+ if (Buffer.byteLength(value, "utf8") > MAX_COMMAND_BYTES) {
61
+ throw new PrismError(400, "invalid_command", { hint: "a batch command cannot exceed 8 KiB" });
62
+ }
63
+ return value;
64
+ }
65
+
49
66
  function assertTrustClass(value) {
50
67
  if (!TRUST_CLASSES.includes(value)) {
51
68
  throw new PrismError(400, "invalid_trust_class", { expected: TRUST_CLASSES });
@@ -90,12 +107,23 @@ export class PrismAgent {
90
107
  this.publicClient = createPublicClient({ chain: robinhoodChain, transport });
91
108
  this.walletClient = createWalletClient({ account: this.account, chain: robinhoodChain, transport });
92
109
  this.session = null;
110
+ this.vault = new PrismVault(this);
93
111
  }
94
112
 
95
113
  get address() {
96
114
  return this.account.address;
97
115
  }
98
116
 
117
+ // The vault key is derived from this signature on the caller's machine. It is
118
+ // returned to the vault client and never sent anywhere.
119
+ async signVaultStatement(statement) {
120
+ return this.account.signMessage({ message: statement });
121
+ }
122
+
123
+ async vaultRequest(method, segments, { body = null } = {}) {
124
+ return this.#proxy(method, ["vault", ...segments], { body });
125
+ }
126
+
99
127
  async authenticate() {
100
128
  const challenge = await this.#json(`/api/agent/challenge?address=${this.address}`);
101
129
  const signature = await this.account.signMessage({ message: challenge.message });
@@ -136,7 +164,7 @@ export class PrismAgent {
136
164
  }
137
165
  }
138
166
 
139
- async quote({ image, durationSeconds, minVramMib = 16000, preferredNodeId = null, minTrustClass = "open" } = {}) {
167
+ async quote({ image, durationSeconds, minVramMib = 16000, preferredNodeId = null, minTrustClass = "open", command = null } = {}) {
140
168
  if (typeof image !== "string" || !/@sha256:[0-9a-f]{64}$/.test(image)) {
141
169
  throw new PrismError(400, "image_must_be_digest_pinned", { hint: "use ollama@sha256:... or DEFAULT_IMAGE" });
142
170
  }
@@ -150,6 +178,7 @@ export class PrismAgent {
150
178
  min_vram_mib: minVramMib,
151
179
  preferred_node_id: preferredNodeId,
152
180
  min_trust_class: assertTrustClass(minTrustClass),
181
+ ...(command === null ? {} : { command: assertCommand(command) }),
153
182
  },
154
183
  },
155
184
  });
@@ -215,6 +244,22 @@ export class PrismAgent {
215
244
  return this.#proxy("GET", ["leases", String(leaseId), "access"]);
216
245
  }
217
246
 
247
+ /// The output of a batch lease, once its node has reported.
248
+ async result(leaseId) {
249
+ return this.#proxy("GET", ["leases", String(leaseId), "result"]);
250
+ }
251
+
252
+ async waitForResult(leaseId, { timeoutMs = 900_000, intervalMs = 10_000 } = {}) {
253
+ const deadline = Date.now() + timeoutMs;
254
+ while (Date.now() < deadline) {
255
+ const res = await this.#proxy("GET", ["leases", String(leaseId), "result"], { raw: true });
256
+ if (res.status === 200) return res.body;
257
+ if (res.status !== 404) throw new PrismError(res.status, res.body?.code ?? "result_failed", res.body);
258
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
259
+ }
260
+ throw new PrismError(408, "result_timeout");
261
+ }
262
+
218
263
  async waitForAccess(leaseId, { timeoutMs = 600_000, intervalMs = 10_000 } = {}) {
219
264
  const deadline = Date.now() + timeoutMs;
220
265
  while (Date.now() < deadline) {
@@ -237,9 +282,17 @@ export class PrismAgent {
237
282
  preferredNodeId = null,
238
283
  maxDeposit = null,
239
284
  minTrustClass = "open",
285
+ command = null,
240
286
  } = {}) {
241
287
  if (!this.session) await this.authenticate();
242
- const quote = await this.quote({ image, durationSeconds, minVramMib, preferredNodeId, minTrustClass });
288
+ const quote = await this.quote({
289
+ image,
290
+ durationSeconds,
291
+ minVramMib,
292
+ preferredNodeId,
293
+ minTrustClass,
294
+ command,
295
+ });
243
296
  if (maxDeposit != null && parseBaseUnits(quote.maximum_escrow, "maximum_escrow") > BigInt(maxDeposit)) {
244
297
  throw new PrismError(402, "cost_exceeds_max", { required: quote.maximum_escrow, max: String(maxDeposit) });
245
298
  }
@@ -252,6 +305,14 @@ export class PrismAgent {
252
305
  sshAuthorizedKey: key.publicKey,
253
306
  });
254
307
  if (!Number.isInteger(record?.lease_id)) throw new PrismError(502, "malformed_lease_record");
308
+ // A batch lease never hands out access, so waiting for it would block
309
+ // until the timeout and then report a failure that never happened. Wait
310
+ // for what the command printed instead.
311
+ if (command !== null) {
312
+ const result = await this.waitForResult(record.lease_id);
313
+ rmSync(key.dir, { recursive: true, force: true });
314
+ return { leaseId: record.lease_id, result, fundingHash: funded.hash, quote };
315
+ }
255
316
  const access = await this.waitForAccess(record.lease_id);
256
317
  return {
257
318
  leaseId: record.lease_id,
package/vault.d.ts ADDED
@@ -0,0 +1,75 @@
1
+ export type TrustFloor = "open" | "isolated" | "attested" | "confidential";
2
+
3
+ export type VaultItem = {
4
+ item_id: string;
5
+ version: number;
6
+ envelope: { wrapped_key: string; nonce: string; ciphertext: string };
7
+ min_trust_class: TrustFloor;
8
+ label: string;
9
+ created_at: string;
10
+ updated_at: string;
11
+ };
12
+
13
+ export type VaultRelease = {
14
+ item_id: string;
15
+ lease_id: number;
16
+ item_version: number;
17
+ lease_trust_class: TrustFloor;
18
+ released_at: string;
19
+ };
20
+
21
+ /// What the vault client needs from its caller: an address, a way to sign, and
22
+ /// a way to reach the control plane. The agent SDK and the browser each supply
23
+ /// their own, so the crypto below is written once.
24
+ export type VaultTransport = {
25
+ address: string;
26
+ session: unknown;
27
+ authenticate: () => Promise<unknown>;
28
+ signVaultStatement: (statement: string) => Promise<string>;
29
+ vaultRequest: (method: string, segments: string[], options?: { body?: unknown }) => Promise<any>;
30
+ };
31
+
32
+ export declare const VAULT_ENVELOPE_DOMAIN: string;
33
+ export declare const VAULT_KEY_STATEMENT: string;
34
+ export declare const DEFAULT_TRUST_FLOOR: TrustFloor;
35
+
36
+ export declare function vaultWallet(address: string): string;
37
+ export declare function associatedData(
38
+ wallet: string,
39
+ itemId: string,
40
+ version: number,
41
+ trustFloor: TrustFloor,
42
+ ): Uint8Array;
43
+
44
+ export declare class PrismVault {
45
+ constructor(transport: VaultTransport);
46
+ readonly unlocked: boolean;
47
+ readonly wallet: string | null;
48
+ unlock(options?: { passphrase?: string | null }): Promise<this>;
49
+ lock(): void;
50
+ list(): Promise<VaultItem[]>;
51
+ releases(): Promise<VaultRelease[]>;
52
+ put(
53
+ value: unknown,
54
+ options?: {
55
+ itemId?: string | null;
56
+ replaces?: VaultItem | null;
57
+ trustFloor?: TrustFloor;
58
+ label?: string;
59
+ },
60
+ ): Promise<VaultItem>;
61
+ get(itemId: string, options?: { json?: boolean }): Promise<any>;
62
+ open(item: VaultItem, options?: { expectVersion?: number | null }): Promise<string>;
63
+ remove(itemId: string): Promise<null>;
64
+ releaseInto(
65
+ lease: number | { leaseId?: number; lease_id?: number },
66
+ itemId: string,
67
+ options?: { json?: boolean },
68
+ ): Promise<any>;
69
+ static permits(trustFloor: TrustFloor, leaseTrustClass: TrustFloor): boolean;
70
+ }
71
+
72
+ export declare class VaultError extends Error {
73
+ readonly code: string;
74
+ readonly body?: unknown;
75
+ }
package/vault.mjs ADDED
@@ -0,0 +1,308 @@
1
+ // Renter-held storage. Everything in this file runs on the renter's machine:
2
+ // the vault key is derived here, items are sealed here, and only ciphertext is
3
+ // handed to Prism. There is no code path that sends a key anywhere, which is
4
+ // the entire reason an agent can keep a card or an identity document in a
5
+ // service it does not trust.
6
+ //
7
+ // Runs unchanged in Node and in the browser, so an agent and the person who
8
+ // owns it seal items identically. Two implementations would be two chances to
9
+ // disagree, and disagreeing here means a vault that will not open.
10
+ const { subtle } = globalThis.crypto;
11
+
12
+ export const VAULT_ENVELOPE_DOMAIN = "prism.vault.v1\0";
13
+
14
+ // A signature over this exact string is the vault key. Nothing else Prism ever
15
+ // asks a wallet to sign resembles it, and no other message derives the same
16
+ // key, so approving a lease cannot hand anyone the vault.
17
+ export const VAULT_KEY_STATEMENT = [
18
+ "Prism Network vault key",
19
+ "",
20
+ "Signing this derives the key that encrypts your Prism vault. It is computed on",
21
+ "this machine and never sent. Anyone who gets this signature can read every item",
22
+ "in the vault, so only sign it in software you trust.",
23
+ "",
24
+ "domain: prism.vault.kdf.v1",
25
+ ].join("\n");
26
+
27
+ // New items are sealed against every trust class the network can serve today.
28
+ // Storing and reading back is unaffected; only handing an item to a rented box
29
+ // is blocked, and that is the operation worth blocking by default.
30
+ export const DEFAULT_TRUST_FLOOR = "confidential";
31
+
32
+ const TRUST_ORDER = ["open", "isolated", "attested", "confidential"];
33
+
34
+ const encoder = new TextEncoder();
35
+ const decoder = new TextDecoder();
36
+
37
+ // Chunked because a 160 KiB item would otherwise spread into more arguments
38
+ // than an engine will accept in one call.
39
+ function b64url(bytes) {
40
+ let binary = "";
41
+ for (let index = 0; index < bytes.length; index += 0x8000) {
42
+ binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
43
+ }
44
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
45
+ }
46
+
47
+ function fromB64url(value) {
48
+ const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/"));
49
+ const bytes = new Uint8Array(binary.length);
50
+ for (let index = 0; index < binary.length; index += 1) {
51
+ bytes[index] = binary.charCodeAt(index);
52
+ }
53
+ return bytes;
54
+ }
55
+
56
+ function fromHex(value) {
57
+ const digits = value.replace(/^0x/, "");
58
+ if (digits.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(digits)) {
59
+ throw new VaultError("invalid_signature_encoding");
60
+ }
61
+ const bytes = new Uint8Array(digits.length / 2);
62
+ for (let index = 0; index < bytes.length; index += 1) {
63
+ bytes[index] = Number.parseInt(digits.slice(index * 2, index * 2 + 2), 16);
64
+ }
65
+ return bytes;
66
+ }
67
+
68
+ /// The wallet address, lowercased. Casing varies by source — a checksummed
69
+ /// address from one wallet and a lowercase one from another must not derive
70
+ /// two different keys for the same vault.
71
+ export function vaultWallet(address) {
72
+ if (typeof address !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(address)) {
73
+ throw new VaultError("invalid_wallet_address", { address });
74
+ }
75
+ return address.toLowerCase();
76
+ }
77
+
78
+ /// Mirrors `vault_associated_data` in prism-protocol byte for byte. A shared
79
+ /// test vector pins both; if they drift, stored items stop opening.
80
+ export function associatedData(wallet, itemId, version, trustFloor) {
81
+ return encoder.encode(
82
+ `${VAULT_ENVELOPE_DOMAIN}${wallet}\0${itemId}\0${version}\0${trustFloor}\0`,
83
+ );
84
+ }
85
+
86
+ function assertTrustFloor(value) {
87
+ if (!TRUST_ORDER.includes(value)) {
88
+ throw new VaultError("invalid_trust_floor", { expected: TRUST_ORDER });
89
+ }
90
+ return value;
91
+ }
92
+
93
+ function meetsFloor(floor, leaseClass) {
94
+ return TRUST_ORDER.indexOf(leaseClass) >= TRUST_ORDER.indexOf(floor);
95
+ }
96
+
97
+ // HKDF over the wallet signature. Ethereum's ECDSA is deterministic (RFC 6979),
98
+ // so the same wallet and message reproduce the same key on any machine: the
99
+ // vault survives a lost laptop without Prism holding an escrow copy. A
100
+ // passphrase, when given, is mixed into the salt, so a leaked signature alone
101
+ // is not enough to open the vault.
102
+ async function deriveRootKey(signature, wallet, passphrase) {
103
+ const material = await subtle.importKey("raw", fromHex(signature), "HKDF", false, ["deriveKey"]);
104
+ const salt = await subtle.digest(
105
+ "SHA-256",
106
+ encoder.encode(`prism.vault.kdf.v1\0${wallet}\0${passphrase ?? ""}`),
107
+ );
108
+ return subtle.deriveKey(
109
+ { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(salt), info: encoder.encode("root") },
110
+ material,
111
+ { name: "AES-KW", length: 256 },
112
+ false,
113
+ ["wrapKey", "unwrapKey"],
114
+ );
115
+ }
116
+
117
+ async function gcm(usages) {
118
+ return subtle.generateKey({ name: "AES-GCM", length: 256 }, true, usages);
119
+ }
120
+
121
+ // Per-item data key, wrapped under the root. The indirection is what makes
122
+ // re-keying cheap: changing the wallet or passphrase rewraps 40 bytes per item
123
+ // instead of re-encrypting every card and passport in the vault.
124
+ async function seal(rootKey, plaintext, aad) {
125
+ const dataKey = await gcm(["encrypt", "decrypt"]);
126
+ const nonce = globalThis.crypto.getRandomValues(new Uint8Array(12));
127
+ const [ciphertext, wrapped] = await Promise.all([
128
+ subtle.encrypt({ name: "AES-GCM", iv: nonce, additionalData: aad }, dataKey, plaintext),
129
+ subtle.wrapKey("raw", dataKey, rootKey, "AES-KW"),
130
+ ]);
131
+ return {
132
+ nonce,
133
+ ciphertext: new Uint8Array(ciphertext),
134
+ wrappedKey: new Uint8Array(wrapped),
135
+ };
136
+ }
137
+
138
+ async function unseal(rootKey, wrappedKey, nonce, ciphertext, aad) {
139
+ let dataKey;
140
+ try {
141
+ dataKey = await subtle.unwrapKey(
142
+ "raw",
143
+ wrappedKey,
144
+ rootKey,
145
+ "AES-KW",
146
+ { name: "AES-GCM", length: 256 },
147
+ false,
148
+ ["decrypt"],
149
+ );
150
+ } catch {
151
+ throw new VaultError("vault_key_mismatch", {
152
+ hint: "this vault key does not open that item; check the wallet and passphrase used to unlock",
153
+ });
154
+ }
155
+ try {
156
+ return new Uint8Array(
157
+ await subtle.decrypt({ name: "AES-GCM", iv: nonce, additionalData: aad }, dataKey, ciphertext),
158
+ );
159
+ } catch {
160
+ throw new VaultError("vault_authentication_failed", {
161
+ hint: "the stored item does not match the account, slot, version and trust floor it was sealed with",
162
+ });
163
+ }
164
+ }
165
+
166
+ export class PrismVault {
167
+ #agent;
168
+ #rootKey = null;
169
+ #wallet = null;
170
+
171
+ constructor(agent) {
172
+ this.#agent = agent;
173
+ }
174
+
175
+ get unlocked() {
176
+ return this.#rootKey !== null;
177
+ }
178
+
179
+ /// Derives the vault key from a wallet signature. Nothing leaves this
180
+ /// process; the signature itself is discarded once the key exists.
181
+ async unlock({ passphrase = null } = {}) {
182
+ if (!this.#agent.session) await this.#agent.authenticate();
183
+ const wallet = vaultWallet(this.#agent.address);
184
+ const signature = await this.#agent.signVaultStatement(VAULT_KEY_STATEMENT);
185
+ this.#rootKey = await deriveRootKey(signature, wallet, passphrase);
186
+ this.#wallet = wallet;
187
+ return this;
188
+ }
189
+
190
+ /// The wallet whose vault is open. One wallet, one vault, whether it is
191
+ /// reached from a browser or from an agent.
192
+ get wallet() {
193
+ return this.#wallet;
194
+ }
195
+
196
+ lock() {
197
+ this.#rootKey = null;
198
+ this.#wallet = null;
199
+ }
200
+
201
+ #require() {
202
+ if (!this.#rootKey) throw new VaultError("vault_locked", { hint: "call unlock() first" });
203
+ }
204
+
205
+ /// Item ids and trust floors only. Names live inside the ciphertext unless
206
+ /// the caller passed a label, so a listing does not disclose what is stored.
207
+ async list() {
208
+ return this.#agent.vaultRequest("GET", ["items"]);
209
+ }
210
+
211
+ async releases() {
212
+ return this.#agent.vaultRequest("GET", ["releases"]);
213
+ }
214
+
215
+ /// Seals `value` and stores it. Pass the item returned by a previous put or
216
+ /// get as `replaces` to update it; omitting that creates a new item and
217
+ /// fails rather than overwriting an existing one.
218
+ async put(value, { itemId = null, replaces = null, trustFloor = DEFAULT_TRUST_FLOOR, label = "" } = {}) {
219
+ this.#require();
220
+ assertTrustFloor(trustFloor);
221
+ const id = replaces?.item_id ?? itemId ?? globalThis.crypto.randomUUID();
222
+ const version = replaces ? replaces.version + 1 : 1;
223
+ const plaintext = encoder.encode(typeof value === "string" ? value : JSON.stringify(value));
224
+ const aad = associatedData(this.#wallet, id, version, trustFloor);
225
+ const { nonce, ciphertext, wrappedKey } = await seal(this.#rootKey, plaintext, aad);
226
+ return this.#agent.vaultRequest("PUT", ["items", id], {
227
+ body: {
228
+ envelope: {
229
+ wrapped_key: b64url(wrappedKey),
230
+ nonce: b64url(nonce),
231
+ ciphertext: b64url(ciphertext),
232
+ },
233
+ min_trust_class: trustFloor,
234
+ label,
235
+ ...(replaces ? { previous_version: replaces.version } : {}),
236
+ },
237
+ });
238
+ }
239
+
240
+ /// Fetches and opens an item. Throws if the service returned anything other
241
+ /// than what this account sealed into that slot at that version.
242
+ async get(itemId, { json = false } = {}) {
243
+ this.#require();
244
+ const item = await this.#agent.vaultRequest("GET", ["items", itemId]);
245
+ const value = await this.open(item);
246
+ return json ? JSON.parse(value) : value;
247
+ }
248
+
249
+ /// Opens an item you already hold. Separate from `get` so a caller can pin a
250
+ /// version they recorded earlier and detect being served an older copy.
251
+ async open(item, { expectVersion = null } = {}) {
252
+ this.#require();
253
+ if (expectVersion !== null && item.version !== expectVersion) {
254
+ throw new VaultError("vault_version_rollback", {
255
+ expected: expectVersion,
256
+ served: item.version,
257
+ });
258
+ }
259
+ const aad = associatedData(
260
+ this.#wallet,
261
+ item.item_id,
262
+ item.version,
263
+ item.min_trust_class,
264
+ );
265
+ const plaintext = await unseal(
266
+ this.#rootKey,
267
+ fromB64url(item.envelope.wrapped_key),
268
+ fromB64url(item.envelope.nonce),
269
+ fromB64url(item.envelope.ciphertext),
270
+ aad,
271
+ );
272
+ return decoder.decode(plaintext);
273
+ }
274
+
275
+ async remove(itemId) {
276
+ return this.#agent.vaultRequest("DELETE", ["items", itemId]);
277
+ }
278
+
279
+ /// Authorizes an item into a running lease and returns its plaintext. The
280
+ /// control plane refuses when the lease's trust class is below the floor the
281
+ /// item was sealed with, so an agent cannot post its owner's card to a host
282
+ /// that can read it by getting a policy check wrong.
283
+ async releaseInto(lease, itemId, { json = false } = {}) {
284
+ this.#require();
285
+ const leaseId = lease?.leaseId ?? lease?.lease_id ?? lease;
286
+ if (!Number.isInteger(leaseId)) throw new VaultError("invalid_lease_handle");
287
+ const item = await this.#agent.vaultRequest("GET", ["items", itemId]);
288
+ await this.#agent.vaultRequest("POST", ["items", itemId, "release"], {
289
+ body: { lease_id: leaseId },
290
+ });
291
+ const value = await this.open(item);
292
+ return json ? JSON.parse(value) : value;
293
+ }
294
+
295
+ /// Whether `releaseInto` would be allowed, without recording a release.
296
+ /// Useful for an agent choosing capacity before it commits to a lease.
297
+ static permits(trustFloor, leaseTrustClass) {
298
+ return meetsFloor(assertTrustFloor(trustFloor), assertTrustFloor(leaseTrustClass));
299
+ }
300
+ }
301
+
302
+ export class VaultError extends Error {
303
+ constructor(code, body) {
304
+ super(`prism vault: ${code}`);
305
+ this.code = code;
306
+ this.body = body;
307
+ }
308
+ }