@nylorun/runtime 0.9.0-beta → 0.11.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 +81 -0
- package/README.md +110 -37
- package/dist/adapters/execution/restate.d.ts +130 -0
- package/dist/adapters/execution/restate.js +442 -0
- package/dist/adapters/media.d.ts +2 -2
- package/dist/adapters/media.js +2 -2
- package/dist/adapters/observe.d.ts +2 -1
- package/dist/adapters/observe.js +2 -3
- package/dist/adapters/sandbox/virtual.js +1 -1
- package/dist/adapters/streams/s2.d.ts +18 -0
- package/dist/adapters/streams/s2.js +361 -0
- package/dist/assets.d.ts +2 -2
- package/dist/assets.js +5 -3
- package/dist/core/executors.d.ts +15 -3
- package/dist/core/executors.js +40 -5
- package/dist/core/flow-host.d.ts +225 -0
- package/dist/core/flow-host.js +518 -0
- package/dist/core/limits.d.ts +40 -0
- package/dist/core/limits.js +76 -0
- package/dist/core/runtime.d.ts +5 -90
- package/dist/core/runtime.js +3 -1447
- package/dist/core/sandbox-routes.d.ts +40 -0
- package/dist/core/sandbox-routes.js +106 -0
- package/dist/core/turn-manifest.d.ts +50 -0
- package/dist/core/turn-manifest.js +66 -0
- package/dist/execution/memory.d.ts +54 -0
- package/dist/execution/memory.js +217 -0
- package/dist/execution/types.d.ts +125 -0
- package/dist/execution/types.js +52 -0
- package/dist/host/config-for.d.ts +17 -0
- package/dist/host/config-for.js +27 -0
- package/dist/host/config.d.ts +16 -0
- package/dist/host/create-host.d.ts +56 -0
- package/dist/host/create-host.js +420 -0
- package/dist/host/environment.d.ts +13 -0
- package/dist/host/environment.js +53 -0
- package/dist/host/execution.d.ts +61 -0
- package/dist/host/execution.js +69 -0
- package/dist/host/http.d.ts +39 -0
- package/dist/host/http.js +142 -0
- package/dist/host/index.d.ts +8 -0
- package/dist/host/index.js +7 -0
- package/dist/host/logger.d.ts +3 -0
- package/dist/host/logger.js +18 -0
- package/dist/host/main.d.ts +3 -0
- package/dist/host/main.js +201 -0
- package/dist/host/stack-config.d.ts +72 -0
- package/dist/host/stack-config.js +193 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +4 -2
- package/dist/infra/database.d.ts +16 -0
- package/dist/infra/database.js +31 -0
- package/dist/infra/execution.d.ts +39 -0
- package/dist/infra/execution.js +68 -0
- package/dist/infra/index.d.ts +42 -0
- package/dist/infra/index.js +56 -0
- package/dist/infra/readiness.d.ts +33 -0
- package/dist/infra/readiness.js +53 -0
- package/dist/infra/streams.d.ts +17 -0
- package/dist/infra/streams.js +33 -0
- package/dist/infra/workers.d.ts +39 -0
- package/dist/infra/workers.js +31 -0
- package/dist/mcp/connect.d.ts +1 -0
- package/dist/mcp/connect.js +2 -0
- package/dist/mcp/pool.d.ts +3 -1
- package/dist/mcp/pool.js +2 -1
- package/dist/mcp/stdio.d.ts +2 -0
- package/dist/mcp/stdio.js +3 -1
- package/dist/model/auth-store.d.ts +1 -1
- package/dist/model/auth-store.js +2 -2
- package/dist/model/models.d.ts +4 -0
- package/dist/model/models.js +22 -26
- package/dist/model/pi-model.d.ts +2 -2
- package/dist/model/pi-model.js +6 -6
- package/dist/model/settings.d.ts +5 -1
- package/dist/model/settings.js +6 -2
- package/dist/sandbox/manager.d.ts +26 -8
- package/dist/sandbox/manager.js +95 -47
- package/dist/sandbox/select.d.ts +5 -4
- package/dist/sandbox/select.js +18 -13
- package/dist/sandbox/share.d.ts +23 -0
- package/dist/sandbox/share.js +58 -0
- package/dist/sandbox/types.d.ts +2 -2
- package/dist/store/canonical.d.ts +5 -0
- package/dist/store/canonical.js +17 -0
- package/dist/store/cursor.d.ts +9 -0
- package/dist/store/cursor.js +18 -0
- package/dist/store/memory.d.ts +57 -0
- package/dist/store/memory.js +643 -0
- package/dist/store/ownership.d.ts +19 -0
- package/dist/store/ownership.js +33 -0
- package/dist/store/postgres/connect.d.ts +25 -0
- package/dist/store/postgres/connect.js +25 -0
- package/dist/store/postgres/locking.d.ts +16 -0
- package/dist/store/postgres/locking.js +22 -0
- package/dist/store/postgres/migrations/001_initial.d.ts +27 -0
- package/dist/store/postgres/migrations/001_initial.js +196 -0
- package/dist/store/postgres/migrations/index.d.ts +49 -0
- package/dist/store/postgres/migrations/index.js +75 -0
- package/dist/store/postgres/names.d.ts +11 -0
- package/dist/store/postgres/names.js +42 -0
- package/dist/store/postgres/store.d.ts +11 -0
- package/dist/store/postgres/store.js +790 -0
- package/dist/store/postgres/tenants.d.ts +90 -0
- package/dist/store/postgres/tenants.js +207 -0
- package/dist/store/types.d.ts +440 -0
- package/dist/store/types.js +10 -0
- package/dist/streams/basin.d.ts +23 -0
- package/dist/streams/basin.js +51 -0
- package/dist/streams/memory.d.ts +25 -0
- package/dist/streams/memory.js +124 -0
- package/dist/streams/relay.d.ts +37 -0
- package/dist/streams/relay.js +254 -0
- package/dist/streams/types.d.ts +115 -0
- package/dist/streams/types.js +86 -0
- package/dist/tenant/actions.d.ts +153 -0
- package/dist/tenant/actions.js +191 -0
- package/dist/tenant/advance.d.ts +15 -0
- package/dist/tenant/advance.js +439 -0
- package/dist/tenant/auth.d.ts +11 -0
- package/dist/tenant/auth.js +34 -0
- package/dist/tenant/commands.d.ts +3 -0
- package/dist/tenant/commands.js +384 -0
- package/dist/tenant/context.d.ts +140 -0
- package/dist/tenant/context.js +40 -0
- package/dist/tenant/effects.d.ts +20 -0
- package/dist/tenant/effects.js +539 -0
- package/dist/tenant/envelope.d.ts +7 -0
- package/dist/tenant/envelope.js +22 -0
- package/dist/tenant/ephemeral.d.ts +42 -0
- package/dist/tenant/ephemeral.js +224 -0
- package/dist/tenant/http.d.ts +32 -0
- package/dist/tenant/http.js +51 -0
- package/dist/tenant/live.d.ts +92 -0
- package/dist/tenant/live.js +290 -0
- package/dist/tenant/logger.d.ts +9 -0
- package/dist/tenant/logger.js +25 -0
- package/dist/tenant/model-setting.d.ts +16 -0
- package/dist/tenant/model-setting.js +12 -0
- package/dist/tenant/module.d.ts +38 -0
- package/dist/tenant/module.js +319 -0
- package/dist/tenant/paths.d.ts +28 -0
- package/dist/tenant/paths.js +91 -0
- package/dist/tenant/pool.d.ts +10 -0
- package/dist/tenant/pool.js +54 -0
- package/dist/tenant/principals.d.ts +13 -0
- package/dist/tenant/principals.js +37 -0
- package/dist/tenant/quarantine-error.d.ts +6 -0
- package/dist/tenant/quarantine-error.js +9 -0
- package/dist/tenant/quarantine.d.ts +27 -0
- package/dist/tenant/quarantine.js +72 -0
- package/dist/tenant/reset.d.ts +22 -0
- package/dist/tenant/reset.js +81 -0
- package/dist/tenant/routes-tenant.d.ts +12 -0
- package/dist/tenant/routes-tenant.js +126 -0
- package/dist/tenant/routes.d.ts +9 -0
- package/dist/tenant/routes.js +142 -0
- package/dist/tenant/runtime.d.ts +81 -0
- package/dist/tenant/runtime.js +298 -0
- package/dist/tenant/scheduler.d.ts +25 -0
- package/dist/tenant/scheduler.js +84 -0
- package/dist/tenant/session.d.ts +60 -0
- package/dist/tenant/session.js +94 -0
- package/dist/tenant/sessions.d.ts +34 -0
- package/dist/tenant/sessions.js +149 -0
- package/dist/tenant/status.d.ts +37 -0
- package/dist/tenant/status.js +130 -0
- package/dist/tenant/store-memory.d.ts +16 -0
- package/dist/tenant/store-memory.js +111 -0
- package/dist/tenant/store-pg.d.ts +15 -0
- package/dist/tenant/store-pg.js +140 -0
- package/dist/tenant/streams.d.ts +126 -0
- package/dist/tenant/streams.js +419 -0
- package/dist/tenant/sweep.d.ts +15 -0
- package/dist/tenant/sweep.js +122 -0
- package/dist/tenant/types.d.ts +205 -0
- package/dist/tenant/types.js +23 -0
- package/dist/tenant/worker.d.ts +84 -0
- package/dist/tenant/worker.js +189 -0
- package/dist/vault/kek.d.ts +5 -2
- package/dist/vault/kek.js +7 -10
- package/dist/vault/service.d.ts +37 -31
- package/dist/vault/service.js +439 -359
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +14 -11
- package/dist/adapters/sandbox/microsandbox.d.ts +0 -2
- package/dist/adapters/sandbox/microsandbox.js +0 -238
- package/dist/core/main.js +0 -35
- package/dist/core/store.d.ts +0 -30
- package/dist/core/store.js +0 -202
- /package/dist/{core/main.d.ts → host/config.js} +0 -0
package/dist/vault/service.js
CHANGED
|
@@ -1,5 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Runtime vault (docs/design/runtime/vault.md, architecture §12.9):
|
|
3
|
+
* administer vaults and credentials, attach them to sessions, and authorize
|
|
4
|
+
* outbound MCP calls. Storage is the async Session Store (`store/types.ts`);
|
|
5
|
+
* this module holds no SQL.
|
|
6
|
+
*
|
|
7
|
+
* ## Public surface
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* new VaultService({ store: SessionStore, kek: () => Buffer, fetch: typeof fetch })
|
|
11
|
+
*
|
|
12
|
+
* // Administration: each opens its own transaction and writes its audit rows
|
|
13
|
+
* // in that transaction. Creates and rotations replay by idempotency key.
|
|
14
|
+
* createVault(body: CreateVaultRequest): Promise<VaultInfo>
|
|
15
|
+
* listVaults(ownerUserId: string): Promise<VaultInfo[]>
|
|
16
|
+
* getVault(id: string): Promise<VaultInfo>
|
|
17
|
+
* deleteVault(id: string): Promise<{ id: string }>
|
|
18
|
+
* createCredential(vaultId: string, body: CreateCredentialRequest): Promise<CredentialInfo>
|
|
19
|
+
* listCredentials(vaultId: string): Promise<CredentialInfo[]>
|
|
20
|
+
* getCredential(vaultId: string, id: string): Promise<CredentialInfo>
|
|
21
|
+
* rotateCredential(vaultId: string, id: string, body: RotateCredentialRequest): Promise<CredentialInfo>
|
|
22
|
+
* deleteCredential(vaultId: string, id: string): Promise<{ id: string }>
|
|
23
|
+
*
|
|
24
|
+
* // Attachment: run inside the caller's session PUT transaction.
|
|
25
|
+
* assertAttachment(t: Tx, ownerUserId: string, vaultIds: readonly string[], selections: readonly CredentialSelection[]): Promise<void>
|
|
26
|
+
* recordAttachment(t: Tx, sessionId: string, vaultIds: readonly string[]): Promise<void>
|
|
27
|
+
*
|
|
28
|
+
* // Use: opens its own transactions; never call it inside one.
|
|
29
|
+
* authorize(input: { sessionId; vaultIds; credentialSelections; url; serverName? }): Promise<AuthorizeResult>
|
|
30
|
+
*
|
|
31
|
+
* // Host model credential: each opens its own transaction.
|
|
32
|
+
* getHostModel(): Promise<HostModelView>
|
|
33
|
+
* listHostProviders(): Promise<{ providers: HostModelProviderInfo[] }>
|
|
34
|
+
* putHostModel(body: PutHostModelRequest): Promise<HostModelView>
|
|
35
|
+
* selectHostModel(body: SelectHostModelRequest): Promise<HostModelView>
|
|
36
|
+
* readHostModel(): Promise<HostModelSecret | undefined>
|
|
37
|
+
* updateHostCredential(credential): Promise<void>
|
|
38
|
+
*
|
|
39
|
+
* // Rejected caller audit: opens its own transaction.
|
|
40
|
+
* reject(route: string): Promise<void>
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* ## Transactions and I/O
|
|
44
|
+
*
|
|
45
|
+
* - No network I/O runs inside a transaction. The KEK getter may touch the
|
|
46
|
+
* filesystem (it creates the key file on first use), so methods that
|
|
47
|
+
* encrypt or decrypt resolve the KEK before opening their transaction and
|
|
48
|
+
* do only in-memory crypto inside it.
|
|
49
|
+
* - `authorize` reads the attached vaults and the matching credential rows
|
|
50
|
+
* (ciphertext only) in one transaction and decides there. A refusal decided
|
|
51
|
+
* from those rows (missing vault, ambiguous, selection mismatch) writes its
|
|
52
|
+
* audit row in that same transaction. Decryption happens after it commits,
|
|
53
|
+
* so the plaintext never enters a transaction. An OAuth refresh calls the
|
|
54
|
+
* token endpoint outside any transaction, then stores the rotated secret and
|
|
55
|
+
* its `refresh` audit row in one follow-up transaction (re-reading the
|
|
56
|
+
* current row there). The `use`/`approved` audit row is written in a final
|
|
57
|
+
* transaction before the header is returned: if that write fails,
|
|
58
|
+
* `authorize` rejects and no header leaves the vault, so every approved use
|
|
59
|
+
* has an audit row. Unreadable ciphertext and refresh failures are audited
|
|
60
|
+
* in their own transaction before the refusal is returned.
|
|
61
|
+
*/
|
|
1
62
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { canonical } from "../
|
|
63
|
+
import { canonical } from "../store/canonical.js";
|
|
3
64
|
import { decryptSecret, encryptSecret, VaultCryptoError, } from "./crypto.js";
|
|
4
65
|
import { VaultError } from "./error.js";
|
|
5
66
|
import { hostModelCatalog } from "../model/catalog.js";
|
|
@@ -8,50 +69,46 @@ const REFRESH_SKEW_MS = 60_000;
|
|
|
8
69
|
const HOST_VAULT_ID = "host";
|
|
9
70
|
const HOST_MODEL_ID = "host-model";
|
|
10
71
|
export class VaultService {
|
|
11
|
-
|
|
12
|
-
tx;
|
|
72
|
+
store;
|
|
13
73
|
kek;
|
|
14
74
|
fetchImpl;
|
|
15
|
-
constructor(
|
|
16
|
-
this.
|
|
17
|
-
this.
|
|
18
|
-
this.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
createVault(body) {
|
|
22
|
-
return this.tx(() => this.replay(`create-vault:${body.idempotencyKey}`, body, () => {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
75
|
+
constructor(options) {
|
|
76
|
+
this.store = options.store;
|
|
77
|
+
this.kek = options.kek;
|
|
78
|
+
this.fetchImpl = options.fetch;
|
|
79
|
+
}
|
|
80
|
+
// --- administration --------------------------------------------------------
|
|
81
|
+
async createVault(body) {
|
|
82
|
+
return this.store.tx((t) => this.replay(t, `create-vault:${body.idempotencyKey}`, body, async () => {
|
|
83
|
+
const row = {
|
|
84
|
+
id: randomUUID(),
|
|
85
|
+
name: body.name,
|
|
86
|
+
ownerUserId: body.ownerUserId,
|
|
87
|
+
metadataJson: body.metadata ? JSON.stringify(body.metadata) : null,
|
|
88
|
+
createdAt: new Date().toISOString(),
|
|
89
|
+
scope: "user",
|
|
90
|
+
};
|
|
91
|
+
await t.insertVault(row);
|
|
92
|
+
await this.audit(t, {
|
|
30
93
|
actor: "application",
|
|
31
94
|
action: "create",
|
|
32
|
-
vaultId: id,
|
|
95
|
+
vaultId: row.id,
|
|
33
96
|
outcome: "created",
|
|
34
97
|
});
|
|
35
|
-
return
|
|
98
|
+
return vaultInfoOf(row);
|
|
36
99
|
}));
|
|
37
100
|
}
|
|
38
|
-
listVaults(ownerUserId) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
return this.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
this.vaultInfo(id);
|
|
50
|
-
const credentials = this.db
|
|
51
|
-
.prepare(`SELECT id FROM vault_credentials WHERE vault_id=?`)
|
|
52
|
-
.all(id);
|
|
53
|
-
for (const credential of credentials) {
|
|
54
|
-
this.audit({
|
|
101
|
+
async listVaults(ownerUserId) {
|
|
102
|
+
return this.store.tx(async (t) => (await t.vaultsByOwner(ownerUserId)).map(vaultInfoOf));
|
|
103
|
+
}
|
|
104
|
+
async getVault(id) {
|
|
105
|
+
return this.store.tx((t) => this.vaultInfo(t, id));
|
|
106
|
+
}
|
|
107
|
+
async deleteVault(id) {
|
|
108
|
+
return this.store.tx(async (t) => {
|
|
109
|
+
await this.vaultInfo(t, id);
|
|
110
|
+
for (const credential of await t.credentialsForVault(id)) {
|
|
111
|
+
await this.audit(t, {
|
|
55
112
|
actor: "application",
|
|
56
113
|
action: "delete",
|
|
57
114
|
vaultId: id,
|
|
@@ -59,9 +116,8 @@ export class VaultService {
|
|
|
59
116
|
outcome: "deleted",
|
|
60
117
|
});
|
|
61
118
|
}
|
|
62
|
-
|
|
63
|
-
this.
|
|
64
|
-
this.audit({
|
|
119
|
+
await t.deleteVault(id);
|
|
120
|
+
await this.audit(t, {
|
|
65
121
|
actor: "application",
|
|
66
122
|
action: "delete",
|
|
67
123
|
vaultId: id,
|
|
@@ -70,50 +126,55 @@ export class VaultService {
|
|
|
70
126
|
return { id };
|
|
71
127
|
});
|
|
72
128
|
}
|
|
73
|
-
createCredential(vaultId, body) {
|
|
74
|
-
|
|
75
|
-
|
|
129
|
+
async createCredential(vaultId, body) {
|
|
130
|
+
const kek = this.kek();
|
|
131
|
+
return this.store.tx((t) => this.replay(t, `create-credential:${vaultId}:${body.idempotencyKey}`, body, async () => {
|
|
132
|
+
await this.vaultInfo(t, vaultId);
|
|
76
133
|
const id = randomUUID();
|
|
77
134
|
const url = normalizeVaultUrl(body.auth.url);
|
|
78
|
-
const
|
|
79
|
-
|
|
135
|
+
const type = body.auth.type;
|
|
136
|
+
const sealed = encryptSecret(kek, credentialAad(vaultId, id, type, url), Buffer.from(JSON.stringify(payloadFromCreate(body)), "utf8"));
|
|
137
|
+
const row = {
|
|
80
138
|
id,
|
|
81
139
|
vaultId,
|
|
82
140
|
name: body.name,
|
|
83
|
-
type
|
|
84
|
-
url,
|
|
141
|
+
type,
|
|
142
|
+
bindingJson: JSON.stringify({ url }),
|
|
85
143
|
expiresAt: expiresFromCreate(body),
|
|
86
|
-
|
|
144
|
+
createdAt: new Date().toISOString(),
|
|
87
145
|
rotatedAt: null,
|
|
88
|
-
|
|
89
|
-
|
|
146
|
+
...sealed,
|
|
147
|
+
};
|
|
148
|
+
await t.insertCredential(row);
|
|
149
|
+
await this.audit(t, {
|
|
90
150
|
actor: "application",
|
|
91
151
|
action: "create",
|
|
92
152
|
vaultId,
|
|
93
153
|
credentialId: id,
|
|
94
154
|
outcome: "created",
|
|
95
155
|
});
|
|
96
|
-
return
|
|
156
|
+
return credentialInfoOf(row);
|
|
97
157
|
}));
|
|
98
158
|
}
|
|
99
|
-
listCredentials(vaultId) {
|
|
100
|
-
this.
|
|
101
|
-
|
|
102
|
-
.
|
|
103
|
-
|
|
104
|
-
|
|
159
|
+
async listCredentials(vaultId) {
|
|
160
|
+
return this.store.tx(async (t) => {
|
|
161
|
+
await this.vaultInfo(t, vaultId);
|
|
162
|
+
return (await t.credentialsForVault(vaultId))
|
|
163
|
+
.filter(isUserCredential)
|
|
164
|
+
.map(credentialInfoOf);
|
|
165
|
+
});
|
|
105
166
|
}
|
|
106
|
-
getCredential(vaultId, id) {
|
|
107
|
-
return this.
|
|
167
|
+
async getCredential(vaultId, id) {
|
|
168
|
+
return this.store.tx(async (t) => credentialInfoOf(await this.credentialRow(t, vaultId, id)));
|
|
108
169
|
}
|
|
109
|
-
rotateCredential(vaultId, id, body) {
|
|
110
|
-
|
|
111
|
-
|
|
170
|
+
async rotateCredential(vaultId, id, body) {
|
|
171
|
+
const kek = this.kek();
|
|
172
|
+
return this.store.tx((t) => this.replay(t, `rotate:${vaultId}:${id}:${body.idempotencyKey}`, body, async () => {
|
|
173
|
+
const row = await this.credentialRow(t, vaultId, id);
|
|
112
174
|
if (row.type !== body.auth.type)
|
|
113
175
|
throw new VaultError(409, "Credential type cannot change");
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
let expiresAt = row.expires_at;
|
|
176
|
+
const next = { ...readPayload(kek, row) };
|
|
177
|
+
let expiresAt = row.expiresAt;
|
|
117
178
|
if (body.auth.type === "bearer")
|
|
118
179
|
next.token = body.auth.token;
|
|
119
180
|
else {
|
|
@@ -123,24 +184,22 @@ export class VaultService {
|
|
|
123
184
|
? null
|
|
124
185
|
: requireTimestamp(body.auth.expiresAt);
|
|
125
186
|
}
|
|
126
|
-
this.writePayload(row, next, expiresAt, new Date().toISOString());
|
|
127
|
-
this.audit({
|
|
187
|
+
const updated = await this.writePayload(t, kek, row, next, expiresAt, new Date().toISOString());
|
|
188
|
+
await this.audit(t, {
|
|
128
189
|
actor: "application",
|
|
129
190
|
action: "rotate",
|
|
130
191
|
vaultId,
|
|
131
192
|
credentialId: id,
|
|
132
193
|
outcome: "rotated",
|
|
133
194
|
});
|
|
134
|
-
return
|
|
195
|
+
return credentialInfoOf(updated);
|
|
135
196
|
}));
|
|
136
197
|
}
|
|
137
|
-
deleteCredential(vaultId, id) {
|
|
138
|
-
return this.tx(() => {
|
|
139
|
-
this.credentialRow(vaultId, id);
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
.run(id, vaultId);
|
|
143
|
-
this.audit({
|
|
198
|
+
async deleteCredential(vaultId, id) {
|
|
199
|
+
return this.store.tx(async (t) => {
|
|
200
|
+
await this.credentialRow(t, vaultId, id);
|
|
201
|
+
await t.deleteCredential(vaultId, id);
|
|
202
|
+
await this.audit(t, {
|
|
144
203
|
actor: "application",
|
|
145
204
|
action: "delete",
|
|
146
205
|
vaultId,
|
|
@@ -150,30 +209,31 @@ export class VaultService {
|
|
|
150
209
|
return { id };
|
|
151
210
|
});
|
|
152
211
|
}
|
|
153
|
-
|
|
212
|
+
// --- attachment (caller's transaction) --------------------------------------
|
|
213
|
+
/** Checks a session's vaults and selections. Run in the session PUT transaction. */
|
|
214
|
+
async assertAttachment(t, ownerUserId, vaultIds, selections) {
|
|
154
215
|
if (new Set(vaultIds).size !== vaultIds.length)
|
|
155
216
|
throw new VaultError(400, "Duplicate vault id");
|
|
156
217
|
if (new Set(selections.map((item) => item.serverName)).size !== selections.length)
|
|
157
218
|
throw new VaultError(400, "Duplicate credential selection");
|
|
158
219
|
for (const id of vaultIds) {
|
|
159
|
-
const vault =
|
|
220
|
+
const vault = await t.getVault(id);
|
|
160
221
|
if (!vault)
|
|
161
222
|
throw new VaultError(404, "Vault not found");
|
|
162
223
|
if (vault.scope === "host")
|
|
163
224
|
throw new VaultError(400, "Host vault cannot be attached to a session");
|
|
164
|
-
if (vault.
|
|
225
|
+
if (vault.ownerUserId !== ownerUserId)
|
|
165
226
|
throw new VaultError(403, "Vault belongs to another user");
|
|
166
227
|
}
|
|
167
228
|
for (const selection of selections) {
|
|
168
|
-
const row =
|
|
169
|
-
|
|
170
|
-
.get(selection.credentialId);
|
|
171
|
-
if (!row || !vaultIds.includes(row.vault_id))
|
|
229
|
+
const row = await t.getCredential(selection.credentialId);
|
|
230
|
+
if (!row || !vaultIds.includes(row.vaultId))
|
|
172
231
|
throw new VaultError(400, "Credential is not in an attached vault");
|
|
173
232
|
}
|
|
174
233
|
}
|
|
175
|
-
|
|
176
|
-
|
|
234
|
+
/** Writes the `attach` audit row. Run in the session PUT transaction. */
|
|
235
|
+
async recordAttachment(t, sessionId, vaultIds) {
|
|
236
|
+
await this.audit(t, {
|
|
177
237
|
actor: "application",
|
|
178
238
|
action: "attach",
|
|
179
239
|
sessionId,
|
|
@@ -181,66 +241,81 @@ export class VaultService {
|
|
|
181
241
|
outcome: "attached",
|
|
182
242
|
});
|
|
183
243
|
}
|
|
244
|
+
// --- use ---------------------------------------------------------------------
|
|
184
245
|
async authorize(input) {
|
|
185
246
|
const url = normalizeVaultUrl(input.url);
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
247
|
+
const decided = await this.store.tx(async (t) => {
|
|
248
|
+
for (const id of input.vaultIds) {
|
|
249
|
+
if (await t.getVault(id))
|
|
250
|
+
continue;
|
|
251
|
+
await this.audit(t, {
|
|
252
|
+
actor: "host",
|
|
253
|
+
action: "use",
|
|
254
|
+
sessionId: input.sessionId,
|
|
255
|
+
target: url,
|
|
256
|
+
outcome: "refused",
|
|
257
|
+
});
|
|
258
|
+
return {
|
|
259
|
+
result: {
|
|
260
|
+
status: "refused",
|
|
261
|
+
url,
|
|
262
|
+
credentialIds: [],
|
|
263
|
+
reason: "vault_missing",
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const matches = [];
|
|
268
|
+
for (const vaultId of input.vaultIds)
|
|
269
|
+
for (const row of await t.credentialsForVault(vaultId))
|
|
270
|
+
if (bindingUrl(row) === url)
|
|
271
|
+
matches.push(row);
|
|
272
|
+
matches.sort((a, b) => a.id.localeCompare(b.id));
|
|
273
|
+
if (matches.length === 0)
|
|
274
|
+
return { result: { status: "unauthenticated", url, headers: {} } };
|
|
275
|
+
const selection = input.serverName
|
|
276
|
+
? input.credentialSelections.find((item) => item.serverName === input.serverName)
|
|
277
|
+
: undefined;
|
|
278
|
+
const chosen = choose(matches, selection);
|
|
279
|
+
if (chosen.kind === "refused") {
|
|
280
|
+
await this.audit(t, {
|
|
281
|
+
actor: "host",
|
|
282
|
+
action: "use",
|
|
283
|
+
sessionId: input.sessionId,
|
|
284
|
+
target: url,
|
|
285
|
+
outcome: "refused",
|
|
286
|
+
});
|
|
287
|
+
return {
|
|
288
|
+
result: {
|
|
289
|
+
status: "refused",
|
|
290
|
+
url,
|
|
291
|
+
credentialIds: chosen.credentialIds,
|
|
292
|
+
reason: chosen.reason,
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
return { row: chosen.row };
|
|
297
|
+
});
|
|
298
|
+
if ("result" in decided)
|
|
299
|
+
return decided.result;
|
|
300
|
+
// Committed. Plaintext exists only from here to the return.
|
|
301
|
+
const row = decided.row;
|
|
302
|
+
const kek = this.kek();
|
|
228
303
|
let payload;
|
|
229
304
|
try {
|
|
230
|
-
payload =
|
|
305
|
+
payload = readPayload(kek, row);
|
|
231
306
|
}
|
|
232
307
|
catch (error) {
|
|
233
308
|
if (!(error instanceof VaultCryptoError))
|
|
234
309
|
throw error;
|
|
235
|
-
this.audit({
|
|
310
|
+
await this.store.tx((t) => this.audit(t, {
|
|
236
311
|
actor: "host",
|
|
237
312
|
action: "use",
|
|
238
|
-
vaultId: row.
|
|
313
|
+
vaultId: row.vaultId,
|
|
239
314
|
credentialId: row.id,
|
|
240
315
|
sessionId: input.sessionId,
|
|
241
316
|
target: url,
|
|
242
317
|
outcome: "refused",
|
|
243
|
-
});
|
|
318
|
+
}));
|
|
244
319
|
return {
|
|
245
320
|
status: "refused",
|
|
246
321
|
url,
|
|
@@ -248,11 +323,10 @@ export class VaultService {
|
|
|
248
323
|
reason: "unreadable",
|
|
249
324
|
};
|
|
250
325
|
}
|
|
251
|
-
if (row.type === "oauth" && dueForRefresh(row.
|
|
252
|
-
const refreshed = await this.refresh(row, payload, input.sessionId, url);
|
|
326
|
+
if (row.type === "oauth" && dueForRefresh(row.expiresAt)) {
|
|
327
|
+
const refreshed = await this.refresh(kek, row, payload, input.sessionId, url);
|
|
253
328
|
if (refreshed.status === "refused")
|
|
254
329
|
return refreshed;
|
|
255
|
-
row = this.credentialRow(row.vault_id, row.id);
|
|
256
330
|
payload = refreshed.payload;
|
|
257
331
|
}
|
|
258
332
|
const token = row.type === "bearer" ? payload.token : payload.accessToken;
|
|
@@ -264,68 +338,62 @@ export class VaultService {
|
|
|
264
338
|
reason: "unreadable",
|
|
265
339
|
};
|
|
266
340
|
}
|
|
267
|
-
this.audit({
|
|
341
|
+
await this.store.tx((t) => this.audit(t, {
|
|
268
342
|
actor: "host",
|
|
269
343
|
action: "use",
|
|
270
|
-
vaultId: row.
|
|
344
|
+
vaultId: row.vaultId,
|
|
271
345
|
credentialId: row.id,
|
|
272
346
|
sessionId: input.sessionId,
|
|
273
347
|
target: url,
|
|
274
348
|
outcome: "approved",
|
|
275
|
-
});
|
|
349
|
+
}));
|
|
276
350
|
return {
|
|
277
351
|
status: "authorized",
|
|
278
352
|
url,
|
|
279
353
|
headers: { authorization: `Bearer ${token}` },
|
|
280
354
|
};
|
|
281
355
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
return {
|
|
288
|
-
|
|
289
|
-
provider
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
authType: binding.authType,
|
|
308
|
-
...(binding.baseUrl ? { baseUrl: binding.baseUrl } : {}),
|
|
309
|
-
lastUpdated: row.rotated_at ?? row.created_at,
|
|
310
|
-
active: binding.provider === active,
|
|
311
|
-
};
|
|
356
|
+
// --- host model ----------------------------------------------------------------
|
|
357
|
+
async getHostModel() {
|
|
358
|
+
return this.store.tx((t) => this.hostModelView(t));
|
|
359
|
+
}
|
|
360
|
+
async listHostProviders() {
|
|
361
|
+
return this.store.tx(async (t) => {
|
|
362
|
+
const active = await this.activeProviderId(t);
|
|
363
|
+
const catalog = new Map(hostModelCatalog().providers.map((provider) => [provider.id, provider.name]));
|
|
364
|
+
const providers = (await this.hostModelRows(t)).map((row) => {
|
|
365
|
+
const binding = modelBinding(row);
|
|
366
|
+
return {
|
|
367
|
+
id: binding.provider,
|
|
368
|
+
name: catalog.get(binding.provider) ??
|
|
369
|
+
(binding.provider === "custom"
|
|
370
|
+
? "Custom OpenAI-compatible"
|
|
371
|
+
: binding.provider),
|
|
372
|
+
model: binding.model,
|
|
373
|
+
authType: binding.authType,
|
|
374
|
+
...(binding.baseUrl ? { baseUrl: binding.baseUrl } : {}),
|
|
375
|
+
lastUpdated: row.rotatedAt ?? row.createdAt,
|
|
376
|
+
active: binding.provider === active,
|
|
377
|
+
};
|
|
378
|
+
});
|
|
379
|
+
providers.sort((left, right) => left.name.localeCompare(right.name));
|
|
380
|
+
return { providers };
|
|
312
381
|
});
|
|
313
|
-
providers.sort((left, right) => left.name.localeCompare(right.name));
|
|
314
|
-
return { providers };
|
|
315
382
|
}
|
|
316
|
-
putHostModel(body) {
|
|
317
|
-
|
|
383
|
+
async putHostModel(body) {
|
|
384
|
+
const kek = this.kek();
|
|
385
|
+
return this.store.tx((t) => this.replay(t, `host-model:${body.idempotencyKey}`, body, async () => {
|
|
318
386
|
this.validateHostModel(body);
|
|
319
|
-
this.ensureHostVault();
|
|
387
|
+
await this.ensureHostVault(t);
|
|
320
388
|
const credentialId = hostModelCredentialId(body.provider);
|
|
321
|
-
this.deleteHostProviderRows(body.provider);
|
|
389
|
+
await this.deleteHostProviderRows(t, body.provider);
|
|
322
390
|
const payload = body.auth.type === "api_key"
|
|
323
391
|
? {
|
|
324
392
|
apiKey: body.auth.key,
|
|
325
393
|
...(body.auth.env ? { env: body.auth.env } : {}),
|
|
326
394
|
}
|
|
327
395
|
: { oauth: body.auth };
|
|
328
|
-
this.insertHostCredential({
|
|
396
|
+
await this.insertHostCredential(t, kek, {
|
|
329
397
|
id: credentialId,
|
|
330
398
|
provider: body.provider,
|
|
331
399
|
model: body.model,
|
|
@@ -333,28 +401,29 @@ export class VaultService {
|
|
|
333
401
|
authType: body.auth.type,
|
|
334
402
|
payload,
|
|
335
403
|
});
|
|
336
|
-
this.setActiveProvider(body.provider);
|
|
337
|
-
this.audit({
|
|
404
|
+
await this.setActiveProvider(t, body.provider);
|
|
405
|
+
await this.audit(t, {
|
|
338
406
|
actor: "application",
|
|
339
407
|
action: "rotate",
|
|
340
408
|
vaultId: HOST_VAULT_ID,
|
|
341
409
|
credentialId,
|
|
342
410
|
outcome: "rotated",
|
|
343
411
|
});
|
|
344
|
-
return this.
|
|
412
|
+
return this.hostModelView(t);
|
|
345
413
|
}));
|
|
346
414
|
}
|
|
347
|
-
selectHostModel(body) {
|
|
348
|
-
|
|
415
|
+
async selectHostModel(body) {
|
|
416
|
+
const kek = this.kek();
|
|
417
|
+
return this.store.tx((t) => this.replay(t, `host-model-select:${body.idempotencyKey}`, body, async () => {
|
|
349
418
|
this.validateHostModel(body);
|
|
350
|
-
const row = this.hostCredentialRowFor(body.provider);
|
|
419
|
+
const row = await this.hostCredentialRowFor(t, body.provider);
|
|
351
420
|
if (!row)
|
|
352
421
|
throw new VaultError(404, "Model provider is not configured");
|
|
353
422
|
const binding = modelBinding(row);
|
|
354
|
-
const payload =
|
|
423
|
+
const payload = readHostPayload(kek, row, binding);
|
|
355
424
|
const credentialId = hostModelCredentialId(body.provider);
|
|
356
|
-
this.deleteHostProviderRows(body.provider);
|
|
357
|
-
this.insertHostCredential({
|
|
425
|
+
await this.deleteHostProviderRows(t, body.provider);
|
|
426
|
+
await this.insertHostCredential(t, kek, {
|
|
358
427
|
id: credentialId,
|
|
359
428
|
provider: body.provider,
|
|
360
429
|
model: body.model,
|
|
@@ -362,23 +431,24 @@ export class VaultService {
|
|
|
362
431
|
authType: binding.authType,
|
|
363
432
|
payload,
|
|
364
433
|
});
|
|
365
|
-
this.setActiveProvider(body.provider);
|
|
366
|
-
this.audit({
|
|
434
|
+
await this.setActiveProvider(t, body.provider);
|
|
435
|
+
await this.audit(t, {
|
|
367
436
|
actor: "application",
|
|
368
437
|
action: "rotate",
|
|
369
438
|
vaultId: HOST_VAULT_ID,
|
|
370
439
|
credentialId,
|
|
371
440
|
outcome: "rotated",
|
|
372
441
|
});
|
|
373
|
-
return this.
|
|
442
|
+
return this.hostModelView(t);
|
|
374
443
|
}));
|
|
375
444
|
}
|
|
376
|
-
|
|
377
|
-
|
|
445
|
+
/** Reads the active host model secret. Decrypts after the read commits. */
|
|
446
|
+
async readHostModel() {
|
|
447
|
+
const row = await this.store.tx((t) => this.activeHostCredentialRow(t));
|
|
378
448
|
if (!row)
|
|
379
449
|
return undefined;
|
|
380
450
|
const binding = modelBinding(row);
|
|
381
|
-
const payload = this.
|
|
451
|
+
const payload = readHostPayload(this.kek(), row, binding);
|
|
382
452
|
const credential = binding.authType === "oauth"
|
|
383
453
|
? { type: "oauth", ...payload.oauth }
|
|
384
454
|
: {
|
|
@@ -394,9 +464,10 @@ export class VaultService {
|
|
|
394
464
|
credential,
|
|
395
465
|
};
|
|
396
466
|
}
|
|
397
|
-
updateHostCredential(credential) {
|
|
398
|
-
this.
|
|
399
|
-
|
|
467
|
+
async updateHostCredential(credential) {
|
|
468
|
+
const kek = this.kek();
|
|
469
|
+
await this.store.tx(async (t) => {
|
|
470
|
+
const row = await this.activeHostCredentialRow(t);
|
|
400
471
|
if (!row)
|
|
401
472
|
throw new VaultError(404, "Model provider is not configured");
|
|
402
473
|
const binding = modelBinding(row);
|
|
@@ -406,34 +477,37 @@ export class VaultService {
|
|
|
406
477
|
apiKey: credential.key,
|
|
407
478
|
...(credential.env ? { env: credential.env } : {}),
|
|
408
479
|
};
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
.
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
480
|
+
const sealed = encryptSecret(kek, hostModelAad(binding.provider, binding.model), Buffer.from(JSON.stringify(payload), "utf8"));
|
|
481
|
+
await t.updateCredential(HOST_VAULT_ID, row.id, {
|
|
482
|
+
type: "model",
|
|
483
|
+
bindingJson: JSON.stringify({ ...binding, authType: credential.type }),
|
|
484
|
+
...sealed,
|
|
485
|
+
rotatedAt: new Date().toISOString(),
|
|
486
|
+
});
|
|
416
487
|
});
|
|
417
488
|
}
|
|
418
|
-
|
|
419
|
-
|
|
489
|
+
// --- rejected callers ---------------------------------------------------------
|
|
490
|
+
async reject(route) {
|
|
491
|
+
await this.store.tx((t) => this.audit(t, {
|
|
420
492
|
actor: "executor",
|
|
421
493
|
action: "reject",
|
|
422
494
|
target: route,
|
|
423
495
|
outcome: "rejected",
|
|
424
|
-
});
|
|
496
|
+
}));
|
|
425
497
|
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
498
|
+
// --- internals -----------------------------------------------------------------
|
|
499
|
+
/** Calls the token endpoint outside any transaction, then stores the result. */
|
|
500
|
+
async refresh(kek, row, payload, sessionId, url) {
|
|
501
|
+
const failRefresh = async () => {
|
|
502
|
+
await this.store.tx((t) => this.audit(t, {
|
|
429
503
|
actor: "host",
|
|
430
504
|
action: "refresh",
|
|
431
|
-
vaultId: row.
|
|
505
|
+
vaultId: row.vaultId,
|
|
432
506
|
credentialId: row.id,
|
|
433
507
|
sessionId,
|
|
434
508
|
target: payload.tokenEndpoint,
|
|
435
509
|
outcome: "refresh_failed",
|
|
436
|
-
});
|
|
510
|
+
}));
|
|
437
511
|
return {
|
|
438
512
|
status: "refused",
|
|
439
513
|
url,
|
|
@@ -443,6 +517,7 @@ export class VaultService {
|
|
|
443
517
|
};
|
|
444
518
|
if (!payload.refreshToken || !payload.tokenEndpoint || !payload.clientId)
|
|
445
519
|
return failRefresh();
|
|
520
|
+
let next;
|
|
446
521
|
try {
|
|
447
522
|
const body = new URLSearchParams({
|
|
448
523
|
grant_type: "refresh_token",
|
|
@@ -474,7 +549,7 @@ export class VaultService {
|
|
|
474
549
|
const json = (await response.json());
|
|
475
550
|
if (typeof json.access_token !== "string" || json.access_token.length === 0)
|
|
476
551
|
return failRefresh();
|
|
477
|
-
|
|
552
|
+
next = {
|
|
478
553
|
...payload,
|
|
479
554
|
accessToken: json.access_token,
|
|
480
555
|
refreshToken: typeof json.refresh_token === "string"
|
|
@@ -484,102 +559,71 @@ export class VaultService {
|
|
|
484
559
|
const expiresAt = typeof json.expires_in === "number" && Number.isFinite(json.expires_in)
|
|
485
560
|
? new Date(Date.now() + json.expires_in * 1000).toISOString()
|
|
486
561
|
: null;
|
|
487
|
-
this.tx(() => {
|
|
488
|
-
const current = this.credentialRow(row.
|
|
489
|
-
this.writePayload(current, next, expiresAt, new Date().toISOString());
|
|
490
|
-
this.audit({
|
|
562
|
+
await this.store.tx(async (t) => {
|
|
563
|
+
const current = await this.credentialRow(t, row.vaultId, row.id);
|
|
564
|
+
await this.writePayload(t, kek, current, next, expiresAt, new Date().toISOString());
|
|
565
|
+
await this.audit(t, {
|
|
491
566
|
actor: "host",
|
|
492
567
|
action: "refresh",
|
|
493
|
-
vaultId: row.
|
|
568
|
+
vaultId: row.vaultId,
|
|
494
569
|
credentialId: row.id,
|
|
495
570
|
sessionId,
|
|
496
571
|
target: payload.tokenEndpoint,
|
|
497
572
|
outcome: "approved",
|
|
498
573
|
});
|
|
499
574
|
});
|
|
500
|
-
return { status: "ok", payload: next };
|
|
501
575
|
}
|
|
502
576
|
catch {
|
|
503
577
|
return failRefresh();
|
|
504
578
|
}
|
|
579
|
+
return { status: "ok", payload: next };
|
|
505
580
|
}
|
|
506
|
-
replay(key, body, create) {
|
|
581
|
+
async replay(t, key, body, create) {
|
|
507
582
|
const hash = createHash("sha256")
|
|
508
583
|
.update(requestHash(body))
|
|
509
584
|
.digest("hex");
|
|
510
|
-
const existing =
|
|
511
|
-
.prepare(`SELECT body_hash, response FROM vault_idempotency WHERE id=?`)
|
|
512
|
-
.get(key);
|
|
585
|
+
const existing = await t.getVaultIdempotency(key);
|
|
513
586
|
if (existing) {
|
|
514
|
-
if (existing.
|
|
587
|
+
if (existing.bodyHash !== hash)
|
|
515
588
|
throw new VaultError(409, "Idempotency key already binds another request");
|
|
516
589
|
return JSON.parse(existing.response);
|
|
517
590
|
}
|
|
518
|
-
const created = create();
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
591
|
+
const created = await create();
|
|
592
|
+
await t.insertVaultIdempotency({
|
|
593
|
+
id: key,
|
|
594
|
+
bodyHash: hash,
|
|
595
|
+
response: JSON.stringify(created),
|
|
596
|
+
});
|
|
522
597
|
return created;
|
|
523
598
|
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
const sealed = encryptSecret(
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
532
|
-
.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);
|
|
533
|
-
}
|
|
534
|
-
writePayload(row, payload, expiresAt, rotatedAt) {
|
|
535
|
-
const url = JSON.parse(row.binding_json).url;
|
|
536
|
-
const aad = credentialAad(row.vault_id, row.id, row.type, url);
|
|
537
|
-
const sealed = encryptSecret(this.kek(), aad, Buffer.from(JSON.stringify(payload), "utf8"));
|
|
538
|
-
this.db
|
|
539
|
-
.prepare(`UPDATE vault_credentials
|
|
540
|
-
SET expires_at=?, rotated_at=?, kek_id=?, nonce=?, ciphertext=?, wrapped_dek=?
|
|
541
|
-
WHERE id=?`)
|
|
542
|
-
.run(expiresAt, rotatedAt, sealed.kekId, sealed.nonce, sealed.ciphertext, sealed.wrappedDek, row.id);
|
|
543
|
-
}
|
|
544
|
-
readPayload(row) {
|
|
545
|
-
const url = JSON.parse(row.binding_json).url;
|
|
546
|
-
const aad = credentialAad(row.vault_id, row.id, row.type, url);
|
|
547
|
-
const plaintext = decryptSecret(this.kek(), aad, Buffer.from(row.nonce), Buffer.from(row.ciphertext), Buffer.from(row.wrapped_dek), row.kek_id);
|
|
548
|
-
try {
|
|
549
|
-
return JSON.parse(plaintext.toString("utf8"));
|
|
550
|
-
}
|
|
551
|
-
finally {
|
|
552
|
-
plaintext.fill(0);
|
|
553
|
-
}
|
|
599
|
+
/** Re-seals a user credential's payload and returns the updated row. */
|
|
600
|
+
async writePayload(t, kek, row, payload, expiresAt, rotatedAt) {
|
|
601
|
+
const sealed = encryptSecret(kek, credentialAad(row.vaultId, row.id, row.type, bindingUrl(row)), Buffer.from(JSON.stringify(payload), "utf8"));
|
|
602
|
+
const patch = { expiresAt, rotatedAt, ...sealed };
|
|
603
|
+
if (!(await t.updateCredential(row.vaultId, row.id, patch)))
|
|
604
|
+
throw new VaultError(404, "Credential not found");
|
|
605
|
+
return { ...row, ...patch };
|
|
554
606
|
}
|
|
555
|
-
vaultInfo(id) {
|
|
556
|
-
const row =
|
|
607
|
+
async vaultInfo(t, id) {
|
|
608
|
+
const row = await t.getVault(id);
|
|
557
609
|
if (!row || row.scope === "host")
|
|
558
610
|
throw new VaultError(404, "Vault not found");
|
|
559
|
-
return
|
|
560
|
-
id: row.id,
|
|
561
|
-
name: row.name,
|
|
562
|
-
ownerUserId: row.owner_user_id,
|
|
563
|
-
...(row.metadata_json
|
|
564
|
-
? { metadata: JSON.parse(row.metadata_json) }
|
|
565
|
-
: {}),
|
|
566
|
-
createdAt: row.created_at,
|
|
567
|
-
};
|
|
568
|
-
}
|
|
569
|
-
vaultRow(id) {
|
|
570
|
-
return this.db
|
|
571
|
-
.prepare(`SELECT id, name, owner_user_id, metadata_json, created_at, scope FROM vaults WHERE id=?`)
|
|
572
|
-
.get(id);
|
|
611
|
+
return vaultInfoOf(row);
|
|
573
612
|
}
|
|
574
|
-
ensureHostVault() {
|
|
575
|
-
const existing =
|
|
613
|
+
async ensureHostVault(t) {
|
|
614
|
+
const existing = await t.getVault(HOST_VAULT_ID);
|
|
576
615
|
if (existing?.scope === "host")
|
|
577
616
|
return;
|
|
578
617
|
if (existing)
|
|
579
618
|
throw new VaultError(409, "Host vault id is already used");
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
619
|
+
await t.insertVault({
|
|
620
|
+
id: HOST_VAULT_ID,
|
|
621
|
+
name: "Host",
|
|
622
|
+
ownerUserId: "host",
|
|
623
|
+
metadataJson: null,
|
|
624
|
+
createdAt: new Date().toISOString(),
|
|
625
|
+
scope: "host",
|
|
626
|
+
});
|
|
583
627
|
}
|
|
584
628
|
validateHostModel(body) {
|
|
585
629
|
if (body.provider === "custom") {
|
|
@@ -604,37 +648,40 @@ export class VaultService {
|
|
|
604
648
|
if (!provider.models.some((item) => item.id === body.model))
|
|
605
649
|
throw new VaultError(400, "Unknown model.");
|
|
606
650
|
}
|
|
607
|
-
insertHostCredential(input) {
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
return JSON.parse(plaintext.toString("utf8"));
|
|
626
|
-
}
|
|
627
|
-
finally {
|
|
628
|
-
plaintext.fill(0);
|
|
629
|
-
}
|
|
651
|
+
async insertHostCredential(t, kek, input) {
|
|
652
|
+
const sealed = encryptSecret(kek, hostModelAad(input.provider, input.model), Buffer.from(JSON.stringify(input.payload), "utf8"));
|
|
653
|
+
await t.insertCredential({
|
|
654
|
+
id: input.id,
|
|
655
|
+
vaultId: HOST_VAULT_ID,
|
|
656
|
+
name: input.provider,
|
|
657
|
+
type: "model",
|
|
658
|
+
bindingJson: JSON.stringify({
|
|
659
|
+
provider: input.provider,
|
|
660
|
+
model: input.model,
|
|
661
|
+
...(input.baseUrl ? { baseUrl: input.baseUrl } : {}),
|
|
662
|
+
authType: input.authType,
|
|
663
|
+
}),
|
|
664
|
+
expiresAt: null,
|
|
665
|
+
createdAt: new Date().toISOString(),
|
|
666
|
+
rotatedAt: null,
|
|
667
|
+
...sealed,
|
|
668
|
+
});
|
|
630
669
|
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
670
|
+
async hostModelView(t) {
|
|
671
|
+
const row = await this.activeHostCredentialRow(t);
|
|
672
|
+
if (!row)
|
|
673
|
+
return { configured: false };
|
|
674
|
+
const binding = modelBinding(row);
|
|
675
|
+
return {
|
|
676
|
+
configured: true,
|
|
677
|
+
provider: binding.provider,
|
|
678
|
+
model: binding.model,
|
|
679
|
+
authType: binding.authType,
|
|
680
|
+
...(binding.baseUrl ? { baseUrl: binding.baseUrl } : {}),
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
async hostModelRows(t) {
|
|
684
|
+
return (await t.credentialsForVault(HOST_VAULT_ID, { type: "model" })).filter((row) => {
|
|
638
685
|
try {
|
|
639
686
|
modelBinding(row);
|
|
640
687
|
return true;
|
|
@@ -644,33 +691,39 @@ export class VaultService {
|
|
|
644
691
|
}
|
|
645
692
|
});
|
|
646
693
|
}
|
|
647
|
-
hostCredentialRowFor(provider) {
|
|
694
|
+
async hostCredentialRowFor(t, provider) {
|
|
648
695
|
const preferred = hostModelCredentialId(provider);
|
|
649
|
-
const rows = this.hostModelRows();
|
|
696
|
+
const rows = await this.hostModelRows(t);
|
|
650
697
|
return (rows.find((row) => row.id === preferred) ??
|
|
651
698
|
rows.find((row) => modelBinding(row).provider === provider));
|
|
652
699
|
}
|
|
653
|
-
activeHostCredentialRow() {
|
|
654
|
-
const active = this.activeProviderId();
|
|
700
|
+
async activeHostCredentialRow(t) {
|
|
701
|
+
const active = await this.activeProviderId(t);
|
|
655
702
|
if (!active)
|
|
656
703
|
return undefined;
|
|
657
|
-
return this.hostCredentialRowFor(active);
|
|
704
|
+
return this.hostCredentialRowFor(t, active);
|
|
658
705
|
}
|
|
659
|
-
activeProviderId() {
|
|
660
|
-
const vault =
|
|
661
|
-
if (vault?.
|
|
706
|
+
async activeProviderId(t) {
|
|
707
|
+
const vault = await t.getVault(HOST_VAULT_ID);
|
|
708
|
+
if (vault?.metadataJson) {
|
|
709
|
+
let activeProvider;
|
|
662
710
|
try {
|
|
663
|
-
|
|
664
|
-
if (typeof metadata.activeProvider === "string" &&
|
|
665
|
-
metadata.activeProvider &&
|
|
666
|
-
this.hostCredentialRowFor(metadata.activeProvider))
|
|
667
|
-
return metadata.activeProvider;
|
|
711
|
+
activeProvider = JSON.parse(vault.metadataJson).activeProvider;
|
|
668
712
|
}
|
|
669
713
|
catch {
|
|
670
714
|
/* Fall through to the only configured provider. */
|
|
671
715
|
}
|
|
716
|
+
if (typeof activeProvider === "string" && activeProvider) {
|
|
717
|
+
try {
|
|
718
|
+
if (await this.hostCredentialRowFor(t, activeProvider))
|
|
719
|
+
return activeProvider;
|
|
720
|
+
}
|
|
721
|
+
catch {
|
|
722
|
+
/* An invalid provider id falls through, as before. */
|
|
723
|
+
}
|
|
724
|
+
}
|
|
672
725
|
}
|
|
673
|
-
const rows = this.hostModelRows();
|
|
726
|
+
const rows = await this.hostModelRows(t);
|
|
674
727
|
if (rows.length === 0)
|
|
675
728
|
return undefined;
|
|
676
729
|
if (rows.length === 1)
|
|
@@ -680,57 +733,84 @@ export class VaultService {
|
|
|
680
733
|
return modelBinding(legacy).provider;
|
|
681
734
|
return modelBinding(rows[0]).provider;
|
|
682
735
|
}
|
|
683
|
-
setActiveProvider(provider) {
|
|
684
|
-
this.ensureHostVault();
|
|
685
|
-
|
|
686
|
-
.prepare(`UPDATE vaults SET metadata_json=? WHERE id=?`)
|
|
687
|
-
.run(JSON.stringify({ activeProvider: provider }), HOST_VAULT_ID);
|
|
736
|
+
async setActiveProvider(t, provider) {
|
|
737
|
+
await this.ensureHostVault(t);
|
|
738
|
+
await t.updateVaultMetadata(HOST_VAULT_ID, JSON.stringify({ activeProvider: provider }));
|
|
688
739
|
}
|
|
689
|
-
deleteHostProviderRows(provider) {
|
|
690
|
-
const ids = this.hostModelRows()
|
|
740
|
+
async deleteHostProviderRows(t, provider) {
|
|
741
|
+
const ids = (await this.hostModelRows(t))
|
|
691
742
|
.filter((row) => modelBinding(row).provider === provider)
|
|
692
743
|
.map((row) => row.id);
|
|
693
744
|
ids.push(hostModelCredentialId(provider));
|
|
694
745
|
for (const id of new Set(ids))
|
|
695
|
-
|
|
696
|
-
.prepare(`DELETE FROM vault_credentials WHERE id=? AND vault_id=?`)
|
|
697
|
-
.run(id, HOST_VAULT_ID);
|
|
746
|
+
await t.deleteCredential(HOST_VAULT_ID, id);
|
|
698
747
|
}
|
|
699
|
-
|
|
700
|
-
const row =
|
|
701
|
-
|
|
702
|
-
id: row.id,
|
|
703
|
-
vaultId: row.vault_id,
|
|
704
|
-
name: row.name,
|
|
705
|
-
type: row.type,
|
|
706
|
-
binding: JSON.parse(row.binding_json),
|
|
707
|
-
...(row.expires_at ? { expiresAt: row.expires_at } : {}),
|
|
708
|
-
createdAt: row.created_at,
|
|
709
|
-
...(row.rotated_at ? { rotatedAt: row.rotated_at } : {}),
|
|
710
|
-
};
|
|
711
|
-
}
|
|
712
|
-
credentialRow(vaultId, id) {
|
|
713
|
-
const row = this.db
|
|
714
|
-
.prepare(`SELECT id, vault_id, name, type, binding_json, expires_at, created_at, rotated_at,
|
|
715
|
-
kek_id, nonce, ciphertext, wrapped_dek
|
|
716
|
-
FROM vault_credentials WHERE id=? AND vault_id=?`)
|
|
717
|
-
.get(id, vaultId);
|
|
718
|
-
if (!row || row.type === "model")
|
|
748
|
+
async credentialRow(t, vaultId, id) {
|
|
749
|
+
const row = await t.getCredential(id);
|
|
750
|
+
if (!row || row.vaultId !== vaultId || !isUserCredential(row))
|
|
719
751
|
throw new VaultError(404, "Credential not found");
|
|
720
752
|
return row;
|
|
721
753
|
}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
.
|
|
754
|
+
async audit(t, entry) {
|
|
755
|
+
await t.insertVaultAudit({
|
|
756
|
+
id: randomUUID(),
|
|
757
|
+
at: new Date().toISOString(),
|
|
758
|
+
actor: entry.actor,
|
|
759
|
+
action: entry.action,
|
|
760
|
+
vaultId: entry.vaultId ?? null,
|
|
761
|
+
credentialId: entry.credentialId ?? null,
|
|
762
|
+
sessionId: entry.sessionId ?? null,
|
|
763
|
+
target: entry.target ?? null,
|
|
764
|
+
outcome: entry.outcome,
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
function isUserCredential(row) {
|
|
769
|
+
return row.type !== "model";
|
|
770
|
+
}
|
|
771
|
+
function bindingUrl(row) {
|
|
772
|
+
return JSON.parse(row.bindingJson).url;
|
|
773
|
+
}
|
|
774
|
+
function vaultInfoOf(row) {
|
|
775
|
+
return {
|
|
776
|
+
id: row.id,
|
|
777
|
+
name: row.name,
|
|
778
|
+
ownerUserId: row.ownerUserId,
|
|
779
|
+
...(row.metadataJson
|
|
780
|
+
? { metadata: JSON.parse(row.metadataJson) }
|
|
781
|
+
: {}),
|
|
782
|
+
createdAt: row.createdAt,
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
function credentialInfoOf(row) {
|
|
786
|
+
return {
|
|
787
|
+
id: row.id,
|
|
788
|
+
vaultId: row.vaultId,
|
|
789
|
+
name: row.name,
|
|
790
|
+
type: row.type,
|
|
791
|
+
binding: JSON.parse(row.bindingJson),
|
|
792
|
+
...(row.expiresAt ? { expiresAt: row.expiresAt } : {}),
|
|
793
|
+
createdAt: row.createdAt,
|
|
794
|
+
...(row.rotatedAt ? { rotatedAt: row.rotatedAt } : {}),
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
function readPayload(kek, row) {
|
|
798
|
+
const aad = credentialAad(row.vaultId, row.id, row.type, bindingUrl(row));
|
|
799
|
+
const plaintext = decryptSecret(kek, aad, Buffer.from(row.nonce), Buffer.from(row.ciphertext), Buffer.from(row.wrappedDek), row.kekId);
|
|
800
|
+
try {
|
|
801
|
+
return JSON.parse(plaintext.toString("utf8"));
|
|
802
|
+
}
|
|
803
|
+
finally {
|
|
804
|
+
plaintext.fill(0);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
function readHostPayload(kek, row, binding) {
|
|
808
|
+
const plaintext = decryptSecret(kek, hostModelAad(binding.provider, binding.model), Buffer.from(row.nonce), Buffer.from(row.ciphertext), Buffer.from(row.wrappedDek), row.kekId);
|
|
809
|
+
try {
|
|
810
|
+
return JSON.parse(plaintext.toString("utf8"));
|
|
728
811
|
}
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
.prepare(`INSERT INTO vault_audit(id, at, actor, action, vault_id, credential_id, session_id, target, outcome)
|
|
732
|
-
VALUES(?,?,?,?,?,?,?,?,?)`)
|
|
733
|
-
.run(randomUUID(), new Date().toISOString(), entry.actor, entry.action, entry.vaultId ?? null, entry.credentialId ?? null, entry.sessionId ?? null, entry.target ?? null, entry.outcome);
|
|
812
|
+
finally {
|
|
813
|
+
plaintext.fill(0);
|
|
734
814
|
}
|
|
735
815
|
}
|
|
736
816
|
function choose(matches, selection) {
|
|
@@ -796,7 +876,7 @@ function requireTimestamp(value) {
|
|
|
796
876
|
return new Date(Date.parse(value)).toISOString();
|
|
797
877
|
}
|
|
798
878
|
function modelBinding(row) {
|
|
799
|
-
const binding = JSON.parse(row.
|
|
879
|
+
const binding = JSON.parse(row.bindingJson);
|
|
800
880
|
if (!binding.provider || !binding.model || !binding.authType)
|
|
801
881
|
throw new VaultError(500, "Host model credential is unreadable");
|
|
802
882
|
return binding;
|