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