@nylorun/runtime 0.6.0-beta → 0.7.0-beta
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 +15 -0
- package/README.md +3 -1
- package/dist/core/runtime.d.ts +17 -0
- package/dist/core/runtime.js +280 -31
- package/dist/core/store.d.ts +1 -0
- package/dist/core/store.js +46 -1
- package/dist/mcp/connect.d.ts +31 -0
- package/dist/mcp/connect.js +190 -0
- package/dist/mcp/pool.d.ts +45 -0
- package/dist/mcp/pool.js +143 -0
- package/dist/mcp/snapshot.d.ts +25 -0
- package/dist/mcp/snapshot.js +10 -0
- package/dist/mcp/stdio.d.ts +15 -0
- package/dist/mcp/stdio.js +80 -0
- package/dist/vault/crypto.d.ts +12 -0
- package/dist/vault/crypto.js +80 -0
- package/dist/vault/error.d.ts +4 -0
- package/dist/vault/error.js +7 -0
- package/dist/vault/kek.d.ts +6 -0
- package/dist/vault/kek.js +26 -0
- package/dist/vault/service.d.ts +59 -0
- package/dist/vault/service.js +538 -0
- package/dist/vault/url.d.ts +3 -0
- package/dist/vault/url.js +27 -0
- package/package.json +6 -5
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { parseKek } from "./crypto.js";
|
|
5
|
+
export function defaultKekPath() {
|
|
6
|
+
return join(process.cwd(), ".nylorun", "vault-kek");
|
|
7
|
+
}
|
|
8
|
+
export function readVaultKek(options) {
|
|
9
|
+
if (options.vaultKek === null)
|
|
10
|
+
return undefined;
|
|
11
|
+
if (options.vaultKek !== undefined)
|
|
12
|
+
return parseKek(options.vaultKek);
|
|
13
|
+
const fromEnv = process.env.NYLORUN_VAULT_KEK;
|
|
14
|
+
if (fromEnv)
|
|
15
|
+
return parseKek(fromEnv);
|
|
16
|
+
const path = options.vaultKekPath ?? defaultKekPath();
|
|
17
|
+
if (!existsSync(path))
|
|
18
|
+
return undefined;
|
|
19
|
+
return parseKek(readFileSync(path, "utf8"));
|
|
20
|
+
}
|
|
21
|
+
export function createKekFile(path) {
|
|
22
|
+
const bytes = randomBytes(32);
|
|
23
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
24
|
+
writeFileSync(path, `${bytes.toString("base64")}\n`, { mode: 0o600 });
|
|
25
|
+
return bytes;
|
|
26
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import type { CreateCredentialRequest, CreateVaultRequest, CredentialInfo, CredentialSelection, RotateCredentialRequest, VaultInfo } from "@nylorun/core/contracts";
|
|
3
|
+
export type AuthorizeResult = {
|
|
4
|
+
status: "unauthenticated";
|
|
5
|
+
url: string;
|
|
6
|
+
headers: Record<string, string>;
|
|
7
|
+
} | {
|
|
8
|
+
status: "authorized";
|
|
9
|
+
url: string;
|
|
10
|
+
headers: {
|
|
11
|
+
authorization: string;
|
|
12
|
+
};
|
|
13
|
+
} | {
|
|
14
|
+
status: "refused";
|
|
15
|
+
url: string;
|
|
16
|
+
credentialIds: string[];
|
|
17
|
+
reason: string;
|
|
18
|
+
};
|
|
19
|
+
export declare class VaultService {
|
|
20
|
+
private readonly db;
|
|
21
|
+
private readonly tx;
|
|
22
|
+
private readonly kek;
|
|
23
|
+
private readonly fetchImpl;
|
|
24
|
+
constructor(db: DatabaseSync, tx: <T>(fn: () => T) => T, kek: () => Buffer, fetchImpl: typeof fetch);
|
|
25
|
+
createVault(body: CreateVaultRequest): VaultInfo;
|
|
26
|
+
listVaults(ownerUserId: string): VaultInfo[];
|
|
27
|
+
getVault(id: string): VaultInfo;
|
|
28
|
+
deleteVault(id: string): {
|
|
29
|
+
id: string;
|
|
30
|
+
};
|
|
31
|
+
createCredential(vaultId: string, body: CreateCredentialRequest): CredentialInfo;
|
|
32
|
+
listCredentials(vaultId: string): CredentialInfo[];
|
|
33
|
+
getCredential(vaultId: string, id: string): CredentialInfo;
|
|
34
|
+
rotateCredential(vaultId: string, id: string, body: RotateCredentialRequest): CredentialInfo;
|
|
35
|
+
deleteCredential(vaultId: string, id: string): {
|
|
36
|
+
id: string;
|
|
37
|
+
};
|
|
38
|
+
assertAttachment(ownerUserId: string, vaultIds: readonly string[], selections: readonly CredentialSelection[]): void;
|
|
39
|
+
recordAttachment(sessionId: string, vaultIds: readonly string[]): void;
|
|
40
|
+
authorize(input: {
|
|
41
|
+
sessionId: string;
|
|
42
|
+
vaultIds: readonly string[];
|
|
43
|
+
credentialSelections: readonly CredentialSelection[];
|
|
44
|
+
url: string;
|
|
45
|
+
serverName?: string;
|
|
46
|
+
}): Promise<AuthorizeResult>;
|
|
47
|
+
reject(route: string): void;
|
|
48
|
+
private refresh;
|
|
49
|
+
private replay;
|
|
50
|
+
private insertCredential;
|
|
51
|
+
private writePayload;
|
|
52
|
+
private readPayload;
|
|
53
|
+
private vaultInfo;
|
|
54
|
+
private vaultRow;
|
|
55
|
+
private credentialInfo;
|
|
56
|
+
private credentialRow;
|
|
57
|
+
private rowsForVault;
|
|
58
|
+
private audit;
|
|
59
|
+
}
|
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { canonical } from "../core/store.js";
|
|
3
|
+
import { decryptSecret, encryptSecret, VaultCryptoError, } from "./crypto.js";
|
|
4
|
+
import { VaultError } from "./error.js";
|
|
5
|
+
import { normalizeVaultUrl } from "./url.js";
|
|
6
|
+
const REFRESH_SKEW_MS = 60_000;
|
|
7
|
+
export class VaultService {
|
|
8
|
+
db;
|
|
9
|
+
tx;
|
|
10
|
+
kek;
|
|
11
|
+
fetchImpl;
|
|
12
|
+
constructor(db, tx, kek, fetchImpl) {
|
|
13
|
+
this.db = db;
|
|
14
|
+
this.tx = tx;
|
|
15
|
+
this.kek = kek;
|
|
16
|
+
this.fetchImpl = fetchImpl;
|
|
17
|
+
}
|
|
18
|
+
createVault(body) {
|
|
19
|
+
return this.tx(() => this.replay(`create-vault:${body.idempotencyKey}`, body, () => {
|
|
20
|
+
const now = new Date().toISOString();
|
|
21
|
+
const id = randomUUID();
|
|
22
|
+
this.db
|
|
23
|
+
.prepare(`INSERT INTO vaults(id,name,owner_user_id,metadata_json,created_at) VALUES(?,?,?,?,?)`)
|
|
24
|
+
.run(id, body.name, body.ownerUserId, body.metadata ? JSON.stringify(body.metadata) : null, now);
|
|
25
|
+
const info = this.vaultInfo(id);
|
|
26
|
+
this.audit({
|
|
27
|
+
actor: "application",
|
|
28
|
+
action: "create",
|
|
29
|
+
vaultId: id,
|
|
30
|
+
outcome: "created",
|
|
31
|
+
});
|
|
32
|
+
return info;
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
listVaults(ownerUserId) {
|
|
36
|
+
const rows = this.db
|
|
37
|
+
.prepare(`SELECT id FROM vaults WHERE owner_user_id=? ORDER BY created_at, id`)
|
|
38
|
+
.all(ownerUserId);
|
|
39
|
+
return rows.map((row) => this.vaultInfo(row.id));
|
|
40
|
+
}
|
|
41
|
+
getVault(id) {
|
|
42
|
+
return this.vaultInfo(id);
|
|
43
|
+
}
|
|
44
|
+
deleteVault(id) {
|
|
45
|
+
return this.tx(() => {
|
|
46
|
+
this.vaultInfo(id);
|
|
47
|
+
const credentials = this.db
|
|
48
|
+
.prepare(`SELECT id FROM vault_credentials WHERE vault_id=?`)
|
|
49
|
+
.all(id);
|
|
50
|
+
for (const credential of credentials) {
|
|
51
|
+
this.audit({
|
|
52
|
+
actor: "application",
|
|
53
|
+
action: "delete",
|
|
54
|
+
vaultId: id,
|
|
55
|
+
credentialId: credential.id,
|
|
56
|
+
outcome: "deleted",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
this.db.prepare(`DELETE FROM vault_credentials WHERE vault_id=?`).run(id);
|
|
60
|
+
this.db.prepare(`DELETE FROM vaults WHERE id=?`).run(id);
|
|
61
|
+
this.audit({
|
|
62
|
+
actor: "application",
|
|
63
|
+
action: "delete",
|
|
64
|
+
vaultId: id,
|
|
65
|
+
outcome: "deleted",
|
|
66
|
+
});
|
|
67
|
+
return { id };
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
createCredential(vaultId, body) {
|
|
71
|
+
return this.tx(() => this.replay(`create-credential:${vaultId}:${body.idempotencyKey}`, body, () => {
|
|
72
|
+
this.vaultInfo(vaultId);
|
|
73
|
+
const id = randomUUID();
|
|
74
|
+
const url = normalizeVaultUrl(body.auth.url);
|
|
75
|
+
const payload = payloadFromCreate(body);
|
|
76
|
+
this.insertCredential({
|
|
77
|
+
id,
|
|
78
|
+
vaultId,
|
|
79
|
+
name: body.name,
|
|
80
|
+
type: body.auth.type,
|
|
81
|
+
url,
|
|
82
|
+
expiresAt: expiresFromCreate(body),
|
|
83
|
+
payload,
|
|
84
|
+
rotatedAt: null,
|
|
85
|
+
});
|
|
86
|
+
this.audit({
|
|
87
|
+
actor: "application",
|
|
88
|
+
action: "create",
|
|
89
|
+
vaultId,
|
|
90
|
+
credentialId: id,
|
|
91
|
+
outcome: "created",
|
|
92
|
+
});
|
|
93
|
+
return this.credentialInfo(vaultId, id);
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
listCredentials(vaultId) {
|
|
97
|
+
this.vaultInfo(vaultId);
|
|
98
|
+
const rows = this.db
|
|
99
|
+
.prepare(`SELECT id FROM vault_credentials WHERE vault_id=? ORDER BY created_at, id`)
|
|
100
|
+
.all(vaultId);
|
|
101
|
+
return rows.map((row) => this.credentialInfo(vaultId, row.id));
|
|
102
|
+
}
|
|
103
|
+
getCredential(vaultId, id) {
|
|
104
|
+
return this.credentialInfo(vaultId, id);
|
|
105
|
+
}
|
|
106
|
+
rotateCredential(vaultId, id, body) {
|
|
107
|
+
return this.tx(() => this.replay(`rotate:${vaultId}:${id}:${body.idempotencyKey}`, body, () => {
|
|
108
|
+
const row = this.credentialRow(vaultId, id);
|
|
109
|
+
if (row.type !== body.auth.type)
|
|
110
|
+
throw new VaultError(409, "Credential type cannot change");
|
|
111
|
+
const current = this.readPayload(row);
|
|
112
|
+
const next = { ...current };
|
|
113
|
+
let expiresAt = row.expires_at;
|
|
114
|
+
if (body.auth.type === "bearer")
|
|
115
|
+
next.token = body.auth.token;
|
|
116
|
+
else {
|
|
117
|
+
next.accessToken = body.auth.accessToken;
|
|
118
|
+
expiresAt =
|
|
119
|
+
body.auth.expiresAt === undefined || body.auth.expiresAt === null
|
|
120
|
+
? null
|
|
121
|
+
: requireTimestamp(body.auth.expiresAt);
|
|
122
|
+
}
|
|
123
|
+
this.writePayload(row, next, expiresAt, new Date().toISOString());
|
|
124
|
+
this.audit({
|
|
125
|
+
actor: "application",
|
|
126
|
+
action: "rotate",
|
|
127
|
+
vaultId,
|
|
128
|
+
credentialId: id,
|
|
129
|
+
outcome: "rotated",
|
|
130
|
+
});
|
|
131
|
+
return this.credentialInfo(vaultId, id);
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
deleteCredential(vaultId, id) {
|
|
135
|
+
return this.tx(() => {
|
|
136
|
+
this.credentialRow(vaultId, id);
|
|
137
|
+
this.db
|
|
138
|
+
.prepare(`DELETE FROM vault_credentials WHERE id=? AND vault_id=?`)
|
|
139
|
+
.run(id, vaultId);
|
|
140
|
+
this.audit({
|
|
141
|
+
actor: "application",
|
|
142
|
+
action: "delete",
|
|
143
|
+
vaultId,
|
|
144
|
+
credentialId: id,
|
|
145
|
+
outcome: "deleted",
|
|
146
|
+
});
|
|
147
|
+
return { id };
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
assertAttachment(ownerUserId, vaultIds, selections) {
|
|
151
|
+
if (new Set(vaultIds).size !== vaultIds.length)
|
|
152
|
+
throw new VaultError(400, "Duplicate vault id");
|
|
153
|
+
if (new Set(selections.map((item) => item.serverName)).size !== selections.length)
|
|
154
|
+
throw new VaultError(400, "Duplicate credential selection");
|
|
155
|
+
for (const id of vaultIds) {
|
|
156
|
+
const vault = this.vaultRow(id);
|
|
157
|
+
if (!vault)
|
|
158
|
+
throw new VaultError(404, "Vault not found");
|
|
159
|
+
if (vault.owner_user_id !== ownerUserId)
|
|
160
|
+
throw new VaultError(403, "Vault belongs to another user");
|
|
161
|
+
}
|
|
162
|
+
for (const selection of selections) {
|
|
163
|
+
const row = this.db
|
|
164
|
+
.prepare(`SELECT vault_id FROM vault_credentials WHERE id=?`)
|
|
165
|
+
.get(selection.credentialId);
|
|
166
|
+
if (!row || !vaultIds.includes(row.vault_id))
|
|
167
|
+
throw new VaultError(400, "Credential is not in an attached vault");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
recordAttachment(sessionId, vaultIds) {
|
|
171
|
+
this.audit({
|
|
172
|
+
actor: "application",
|
|
173
|
+
action: "attach",
|
|
174
|
+
sessionId,
|
|
175
|
+
target: vaultIds.join(","),
|
|
176
|
+
outcome: "attached",
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
async authorize(input) {
|
|
180
|
+
const url = normalizeVaultUrl(input.url);
|
|
181
|
+
const missing = input.vaultIds.filter((id) => !this.vaultRow(id));
|
|
182
|
+
if (missing.length > 0) {
|
|
183
|
+
this.audit({
|
|
184
|
+
actor: "host",
|
|
185
|
+
action: "use",
|
|
186
|
+
sessionId: input.sessionId,
|
|
187
|
+
target: url,
|
|
188
|
+
outcome: "refused",
|
|
189
|
+
});
|
|
190
|
+
return {
|
|
191
|
+
status: "refused",
|
|
192
|
+
url,
|
|
193
|
+
credentialIds: [],
|
|
194
|
+
reason: "vault_missing",
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const matches = input.vaultIds
|
|
198
|
+
.flatMap((vaultId) => this.rowsForVault(vaultId))
|
|
199
|
+
.filter((row) => JSON.parse(row.binding_json).url === url)
|
|
200
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
201
|
+
const selection = input.serverName
|
|
202
|
+
? input.credentialSelections.find((item) => item.serverName === input.serverName)
|
|
203
|
+
: undefined;
|
|
204
|
+
if (matches.length === 0)
|
|
205
|
+
return { status: "unauthenticated", url, headers: {} };
|
|
206
|
+
const chosen = choose(matches, selection);
|
|
207
|
+
if (chosen.kind === "refused") {
|
|
208
|
+
this.audit({
|
|
209
|
+
actor: "host",
|
|
210
|
+
action: "use",
|
|
211
|
+
sessionId: input.sessionId,
|
|
212
|
+
target: url,
|
|
213
|
+
outcome: "refused",
|
|
214
|
+
});
|
|
215
|
+
return {
|
|
216
|
+
status: "refused",
|
|
217
|
+
url,
|
|
218
|
+
credentialIds: chosen.credentialIds,
|
|
219
|
+
reason: chosen.reason,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
let row = chosen.row;
|
|
223
|
+
let payload;
|
|
224
|
+
try {
|
|
225
|
+
payload = this.readPayload(row);
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
if (!(error instanceof VaultCryptoError))
|
|
229
|
+
throw error;
|
|
230
|
+
this.audit({
|
|
231
|
+
actor: "host",
|
|
232
|
+
action: "use",
|
|
233
|
+
vaultId: row.vault_id,
|
|
234
|
+
credentialId: row.id,
|
|
235
|
+
sessionId: input.sessionId,
|
|
236
|
+
target: url,
|
|
237
|
+
outcome: "refused",
|
|
238
|
+
});
|
|
239
|
+
return {
|
|
240
|
+
status: "refused",
|
|
241
|
+
url,
|
|
242
|
+
credentialIds: [row.id],
|
|
243
|
+
reason: "unreadable",
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
if (row.type === "oauth" && dueForRefresh(row.expires_at)) {
|
|
247
|
+
const refreshed = await this.refresh(row, payload, input.sessionId, url);
|
|
248
|
+
if (refreshed.status === "refused")
|
|
249
|
+
return refreshed;
|
|
250
|
+
row = this.credentialRow(row.vault_id, row.id);
|
|
251
|
+
payload = refreshed.payload;
|
|
252
|
+
}
|
|
253
|
+
const token = row.type === "bearer" ? payload.token : payload.accessToken;
|
|
254
|
+
if (!token) {
|
|
255
|
+
return {
|
|
256
|
+
status: "refused",
|
|
257
|
+
url,
|
|
258
|
+
credentialIds: [row.id],
|
|
259
|
+
reason: "unreadable",
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
this.audit({
|
|
263
|
+
actor: "host",
|
|
264
|
+
action: "use",
|
|
265
|
+
vaultId: row.vault_id,
|
|
266
|
+
credentialId: row.id,
|
|
267
|
+
sessionId: input.sessionId,
|
|
268
|
+
target: url,
|
|
269
|
+
outcome: "approved",
|
|
270
|
+
});
|
|
271
|
+
return {
|
|
272
|
+
status: "authorized",
|
|
273
|
+
url,
|
|
274
|
+
headers: { authorization: `Bearer ${token}` },
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
reject(route) {
|
|
278
|
+
this.audit({
|
|
279
|
+
actor: "executor",
|
|
280
|
+
action: "reject",
|
|
281
|
+
target: route,
|
|
282
|
+
outcome: "rejected",
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
async refresh(row, payload, sessionId, url) {
|
|
286
|
+
const failRefresh = () => {
|
|
287
|
+
this.audit({
|
|
288
|
+
actor: "host",
|
|
289
|
+
action: "refresh",
|
|
290
|
+
vaultId: row.vault_id,
|
|
291
|
+
credentialId: row.id,
|
|
292
|
+
sessionId,
|
|
293
|
+
target: payload.tokenEndpoint,
|
|
294
|
+
outcome: "refresh_failed",
|
|
295
|
+
});
|
|
296
|
+
return {
|
|
297
|
+
status: "refused",
|
|
298
|
+
url,
|
|
299
|
+
credentialIds: [row.id],
|
|
300
|
+
reason: "refresh_failed",
|
|
301
|
+
};
|
|
302
|
+
};
|
|
303
|
+
if (!payload.refreshToken || !payload.tokenEndpoint || !payload.clientId)
|
|
304
|
+
return failRefresh();
|
|
305
|
+
try {
|
|
306
|
+
const body = new URLSearchParams({
|
|
307
|
+
grant_type: "refresh_token",
|
|
308
|
+
refresh_token: payload.refreshToken,
|
|
309
|
+
client_id: payload.clientId,
|
|
310
|
+
});
|
|
311
|
+
const headers = {
|
|
312
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
313
|
+
};
|
|
314
|
+
if (payload.tokenEndpointAuth === "client_secret_post") {
|
|
315
|
+
if (!payload.clientSecret)
|
|
316
|
+
return failRefresh();
|
|
317
|
+
body.set("client_secret", payload.clientSecret);
|
|
318
|
+
}
|
|
319
|
+
if (payload.tokenEndpointAuth === "client_secret_basic") {
|
|
320
|
+
if (!payload.clientSecret)
|
|
321
|
+
return failRefresh();
|
|
322
|
+
const encoded = Buffer.from(`${encodeURIComponent(payload.clientId)}:${encodeURIComponent(payload.clientSecret)}`).toString("base64");
|
|
323
|
+
headers.authorization = `Basic ${encoded}`;
|
|
324
|
+
}
|
|
325
|
+
const response = await this.fetchImpl(payload.tokenEndpoint, {
|
|
326
|
+
method: "POST",
|
|
327
|
+
headers,
|
|
328
|
+
body: body.toString(),
|
|
329
|
+
redirect: "error",
|
|
330
|
+
});
|
|
331
|
+
if (!response.ok)
|
|
332
|
+
return failRefresh();
|
|
333
|
+
const json = (await response.json());
|
|
334
|
+
if (typeof json.access_token !== "string" || json.access_token.length === 0)
|
|
335
|
+
return failRefresh();
|
|
336
|
+
const next = {
|
|
337
|
+
...payload,
|
|
338
|
+
accessToken: json.access_token,
|
|
339
|
+
refreshToken: typeof json.refresh_token === "string"
|
|
340
|
+
? json.refresh_token
|
|
341
|
+
: payload.refreshToken,
|
|
342
|
+
};
|
|
343
|
+
const expiresAt = typeof json.expires_in === "number" && Number.isFinite(json.expires_in)
|
|
344
|
+
? new Date(Date.now() + json.expires_in * 1000).toISOString()
|
|
345
|
+
: null;
|
|
346
|
+
this.tx(() => {
|
|
347
|
+
const current = this.credentialRow(row.vault_id, row.id);
|
|
348
|
+
this.writePayload(current, next, expiresAt, new Date().toISOString());
|
|
349
|
+
this.audit({
|
|
350
|
+
actor: "host",
|
|
351
|
+
action: "refresh",
|
|
352
|
+
vaultId: row.vault_id,
|
|
353
|
+
credentialId: row.id,
|
|
354
|
+
sessionId,
|
|
355
|
+
target: payload.tokenEndpoint,
|
|
356
|
+
outcome: "approved",
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
return { status: "ok", payload: next };
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
return failRefresh();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
replay(key, body, create) {
|
|
366
|
+
const hash = createHash("sha256")
|
|
367
|
+
.update(requestHash(body))
|
|
368
|
+
.digest("hex");
|
|
369
|
+
const existing = this.db
|
|
370
|
+
.prepare(`SELECT body_hash, response FROM vault_idempotency WHERE id=?`)
|
|
371
|
+
.get(key);
|
|
372
|
+
if (existing) {
|
|
373
|
+
if (existing.body_hash !== hash)
|
|
374
|
+
throw new VaultError(409, "Idempotency key already binds another request");
|
|
375
|
+
return JSON.parse(existing.response);
|
|
376
|
+
}
|
|
377
|
+
const created = create();
|
|
378
|
+
this.db
|
|
379
|
+
.prepare(`INSERT INTO vault_idempotency(id, body_hash, response) VALUES(?,?,?)`)
|
|
380
|
+
.run(key, hash, JSON.stringify(created));
|
|
381
|
+
return created;
|
|
382
|
+
}
|
|
383
|
+
insertCredential(input) {
|
|
384
|
+
const aad = credentialAad(input.vaultId, input.id, input.type, input.url);
|
|
385
|
+
const sealed = encryptSecret(this.kek(), aad, Buffer.from(JSON.stringify(input.payload), "utf8"));
|
|
386
|
+
this.db
|
|
387
|
+
.prepare(`INSERT INTO vault_credentials(
|
|
388
|
+
id, vault_id, name, type, binding_json, expires_at, created_at, rotated_at,
|
|
389
|
+
kek_id, nonce, ciphertext, wrapped_dek
|
|
390
|
+
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
391
|
+
.run(input.id, input.vaultId, input.name, input.type, JSON.stringify({ url: input.url }), input.expiresAt, new Date().toISOString(), input.rotatedAt, sealed.kekId, sealed.nonce, sealed.ciphertext, sealed.wrappedDek);
|
|
392
|
+
}
|
|
393
|
+
writePayload(row, payload, expiresAt, rotatedAt) {
|
|
394
|
+
const url = JSON.parse(row.binding_json).url;
|
|
395
|
+
const aad = credentialAad(row.vault_id, row.id, row.type, url);
|
|
396
|
+
const sealed = encryptSecret(this.kek(), aad, Buffer.from(JSON.stringify(payload), "utf8"));
|
|
397
|
+
this.db
|
|
398
|
+
.prepare(`UPDATE vault_credentials
|
|
399
|
+
SET expires_at=?, rotated_at=?, kek_id=?, nonce=?, ciphertext=?, wrapped_dek=?
|
|
400
|
+
WHERE id=?`)
|
|
401
|
+
.run(expiresAt, rotatedAt, sealed.kekId, sealed.nonce, sealed.ciphertext, sealed.wrappedDek, row.id);
|
|
402
|
+
}
|
|
403
|
+
readPayload(row) {
|
|
404
|
+
const url = JSON.parse(row.binding_json).url;
|
|
405
|
+
const aad = credentialAad(row.vault_id, row.id, row.type, url);
|
|
406
|
+
const plaintext = decryptSecret(this.kek(), aad, Buffer.from(row.nonce), Buffer.from(row.ciphertext), Buffer.from(row.wrapped_dek), row.kek_id);
|
|
407
|
+
try {
|
|
408
|
+
return JSON.parse(plaintext.toString("utf8"));
|
|
409
|
+
}
|
|
410
|
+
finally {
|
|
411
|
+
plaintext.fill(0);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
vaultInfo(id) {
|
|
415
|
+
const row = this.vaultRow(id);
|
|
416
|
+
if (!row)
|
|
417
|
+
throw new VaultError(404, "Vault not found");
|
|
418
|
+
return {
|
|
419
|
+
id: row.id,
|
|
420
|
+
name: row.name,
|
|
421
|
+
ownerUserId: row.owner_user_id,
|
|
422
|
+
...(row.metadata_json
|
|
423
|
+
? { metadata: JSON.parse(row.metadata_json) }
|
|
424
|
+
: {}),
|
|
425
|
+
createdAt: row.created_at,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
vaultRow(id) {
|
|
429
|
+
return this.db
|
|
430
|
+
.prepare(`SELECT id, name, owner_user_id, metadata_json, created_at FROM vaults WHERE id=?`)
|
|
431
|
+
.get(id);
|
|
432
|
+
}
|
|
433
|
+
credentialInfo(vaultId, id) {
|
|
434
|
+
const row = this.credentialRow(vaultId, id);
|
|
435
|
+
return {
|
|
436
|
+
id: row.id,
|
|
437
|
+
vaultId: row.vault_id,
|
|
438
|
+
name: row.name,
|
|
439
|
+
type: row.type,
|
|
440
|
+
binding: JSON.parse(row.binding_json),
|
|
441
|
+
...(row.expires_at ? { expiresAt: row.expires_at } : {}),
|
|
442
|
+
createdAt: row.created_at,
|
|
443
|
+
...(row.rotated_at ? { rotatedAt: row.rotated_at } : {}),
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
credentialRow(vaultId, id) {
|
|
447
|
+
const row = this.db
|
|
448
|
+
.prepare(`SELECT id, vault_id, name, type, binding_json, expires_at, created_at, rotated_at,
|
|
449
|
+
kek_id, nonce, ciphertext, wrapped_dek
|
|
450
|
+
FROM vault_credentials WHERE id=? AND vault_id=?`)
|
|
451
|
+
.get(id, vaultId);
|
|
452
|
+
if (!row || (row.type !== "bearer" && row.type !== "oauth"))
|
|
453
|
+
throw new VaultError(404, "Credential not found");
|
|
454
|
+
return row;
|
|
455
|
+
}
|
|
456
|
+
rowsForVault(vaultId) {
|
|
457
|
+
return this.db
|
|
458
|
+
.prepare(`SELECT id, vault_id, name, type, binding_json, expires_at, created_at, rotated_at,
|
|
459
|
+
kek_id, nonce, ciphertext, wrapped_dek
|
|
460
|
+
FROM vault_credentials WHERE vault_id=?`)
|
|
461
|
+
.all(vaultId);
|
|
462
|
+
}
|
|
463
|
+
audit(entry) {
|
|
464
|
+
this.db
|
|
465
|
+
.prepare(`INSERT INTO vault_audit(id, at, actor, action, vault_id, credential_id, session_id, target, outcome)
|
|
466
|
+
VALUES(?,?,?,?,?,?,?,?,?)`)
|
|
467
|
+
.run(randomUUID(), new Date().toISOString(), entry.actor, entry.action, entry.vaultId ?? null, entry.credentialId ?? null, entry.sessionId ?? null, entry.target ?? null, entry.outcome);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
function choose(matches, selection) {
|
|
471
|
+
const ids = matches.map((row) => row.id);
|
|
472
|
+
if (matches.length === 1) {
|
|
473
|
+
const only = matches[0];
|
|
474
|
+
if (selection && selection.credentialId !== only.id)
|
|
475
|
+
return {
|
|
476
|
+
kind: "refused",
|
|
477
|
+
credentialIds: ids,
|
|
478
|
+
reason: "selection_mismatch",
|
|
479
|
+
};
|
|
480
|
+
return { kind: "use", row: only };
|
|
481
|
+
}
|
|
482
|
+
if (!selection)
|
|
483
|
+
return { kind: "refused", credentialIds: ids, reason: "ambiguous" };
|
|
484
|
+
const chosen = matches.find((row) => row.id === selection.credentialId);
|
|
485
|
+
if (!chosen)
|
|
486
|
+
return {
|
|
487
|
+
kind: "refused",
|
|
488
|
+
credentialIds: ids,
|
|
489
|
+
reason: "selection_mismatch",
|
|
490
|
+
};
|
|
491
|
+
return { kind: "use", row: chosen };
|
|
492
|
+
}
|
|
493
|
+
function dueForRefresh(expiresAt) {
|
|
494
|
+
if (!expiresAt)
|
|
495
|
+
return false;
|
|
496
|
+
return Date.parse(expiresAt) - Date.now() <= REFRESH_SKEW_MS;
|
|
497
|
+
}
|
|
498
|
+
function payloadFromCreate(body) {
|
|
499
|
+
if (body.auth.type === "bearer")
|
|
500
|
+
return { token: body.auth.token };
|
|
501
|
+
const refresh = body.auth.refresh;
|
|
502
|
+
const tokenEndpoint = refresh
|
|
503
|
+
? normalizeVaultUrl(refresh.tokenEndpoint, { httpsOnly: true })
|
|
504
|
+
: undefined;
|
|
505
|
+
if (body.auth.expiresAt)
|
|
506
|
+
requireTimestamp(body.auth.expiresAt);
|
|
507
|
+
return {
|
|
508
|
+
accessToken: body.auth.accessToken,
|
|
509
|
+
...(refresh
|
|
510
|
+
? {
|
|
511
|
+
refreshToken: refresh.refreshToken,
|
|
512
|
+
tokenEndpoint,
|
|
513
|
+
clientId: refresh.clientId,
|
|
514
|
+
tokenEndpointAuth: refresh.tokenEndpointAuth.type,
|
|
515
|
+
...(refresh.tokenEndpointAuth.type === "none"
|
|
516
|
+
? {}
|
|
517
|
+
: { clientSecret: refresh.tokenEndpointAuth.clientSecret }),
|
|
518
|
+
}
|
|
519
|
+
: {}),
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
function expiresFromCreate(body) {
|
|
523
|
+
if (body.auth.type !== "oauth" || !body.auth.expiresAt)
|
|
524
|
+
return null;
|
|
525
|
+
return requireTimestamp(body.auth.expiresAt);
|
|
526
|
+
}
|
|
527
|
+
function requireTimestamp(value) {
|
|
528
|
+
if (Number.isNaN(Date.parse(value)))
|
|
529
|
+
throw new VaultError(400, "Credential expiry is invalid");
|
|
530
|
+
return new Date(Date.parse(value)).toISOString();
|
|
531
|
+
}
|
|
532
|
+
function credentialAad(vaultId, credentialId, type, url) {
|
|
533
|
+
return Buffer.from(canonical({ vaultId, credentialId, type, url }), "utf8");
|
|
534
|
+
}
|
|
535
|
+
function requestHash(body) {
|
|
536
|
+
const { requestId: _requestId, ...rest } = body;
|
|
537
|
+
return canonical(rest);
|
|
538
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { VaultError } from "./error.js";
|
|
2
|
+
export function normalizeVaultUrl(input, options = {}) {
|
|
3
|
+
let url;
|
|
4
|
+
try {
|
|
5
|
+
url = new URL(input);
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
throw new VaultError(400, "Credential url is invalid");
|
|
9
|
+
}
|
|
10
|
+
if (url.username !== "" || url.password !== "")
|
|
11
|
+
throw new VaultError(400, "Credential url must not contain userinfo");
|
|
12
|
+
if (url.hash !== "")
|
|
13
|
+
throw new VaultError(400, "Credential url must not contain a fragment");
|
|
14
|
+
if (url.hostname.includes("*") || url.pathname.includes("*"))
|
|
15
|
+
throw new VaultError(400, "Credential url must not contain a wildcard");
|
|
16
|
+
if (options.httpsOnly) {
|
|
17
|
+
if (url.protocol !== "https:")
|
|
18
|
+
throw new VaultError(400, "Credential url must use https");
|
|
19
|
+
}
|
|
20
|
+
else if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
21
|
+
throw new VaultError(400, "Credential url must use http or https");
|
|
22
|
+
}
|
|
23
|
+
if ((url.protocol === "https:" && url.port === "443") ||
|
|
24
|
+
(url.protocol === "http:" && url.port === "80"))
|
|
25
|
+
url.port = "";
|
|
26
|
+
return `${url.origin}${url.pathname}${url.search}`;
|
|
27
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nylorun/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0-beta",
|
|
4
4
|
"description": "SQLite agent execution host and model providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -51,16 +51,17 @@
|
|
|
51
51
|
"start": "node dist/core/main.js"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
54
55
|
"@earendil-works/pi-ai": "0.85.1",
|
|
55
56
|
"@hono/node-server": "^2.1.1",
|
|
56
|
-
"@nylorun/harness": "0.
|
|
57
|
-
"@nylorun/core": "0.
|
|
57
|
+
"@nylorun/harness": "0.16.0-beta",
|
|
58
|
+
"@nylorun/core": "0.2.0-beta"
|
|
58
59
|
},
|
|
59
60
|
"devDependencies": {
|
|
60
61
|
"@types/node": "^26.5.0",
|
|
61
62
|
"@typescript/native": "npm:typescript@^7.0.2",
|
|
62
|
-
"hono": "^4.13.
|
|
63
|
+
"hono": "^4.13.8",
|
|
63
64
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
64
|
-
"vitest": "^5.0.
|
|
65
|
+
"vitest": "^5.0.1"
|
|
65
66
|
}
|
|
66
67
|
}
|