@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.
Files changed (192) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +110 -37
  3. package/dist/adapters/execution/restate.d.ts +130 -0
  4. package/dist/adapters/execution/restate.js +442 -0
  5. package/dist/adapters/media.d.ts +2 -2
  6. package/dist/adapters/media.js +2 -2
  7. package/dist/adapters/observe.d.ts +2 -1
  8. package/dist/adapters/observe.js +2 -3
  9. package/dist/adapters/sandbox/virtual.js +1 -1
  10. package/dist/adapters/streams/s2.d.ts +18 -0
  11. package/dist/adapters/streams/s2.js +361 -0
  12. package/dist/assets.d.ts +2 -2
  13. package/dist/assets.js +5 -3
  14. package/dist/core/executors.d.ts +15 -3
  15. package/dist/core/executors.js +40 -5
  16. package/dist/core/flow-host.d.ts +225 -0
  17. package/dist/core/flow-host.js +518 -0
  18. package/dist/core/limits.d.ts +40 -0
  19. package/dist/core/limits.js +76 -0
  20. package/dist/core/runtime.d.ts +5 -90
  21. package/dist/core/runtime.js +3 -1447
  22. package/dist/core/sandbox-routes.d.ts +40 -0
  23. package/dist/core/sandbox-routes.js +106 -0
  24. package/dist/core/turn-manifest.d.ts +50 -0
  25. package/dist/core/turn-manifest.js +66 -0
  26. package/dist/execution/memory.d.ts +54 -0
  27. package/dist/execution/memory.js +217 -0
  28. package/dist/execution/types.d.ts +125 -0
  29. package/dist/execution/types.js +52 -0
  30. package/dist/host/config-for.d.ts +17 -0
  31. package/dist/host/config-for.js +27 -0
  32. package/dist/host/config.d.ts +16 -0
  33. package/dist/host/create-host.d.ts +56 -0
  34. package/dist/host/create-host.js +420 -0
  35. package/dist/host/environment.d.ts +13 -0
  36. package/dist/host/environment.js +53 -0
  37. package/dist/host/execution.d.ts +61 -0
  38. package/dist/host/execution.js +69 -0
  39. package/dist/host/http.d.ts +39 -0
  40. package/dist/host/http.js +142 -0
  41. package/dist/host/index.d.ts +8 -0
  42. package/dist/host/index.js +7 -0
  43. package/dist/host/logger.d.ts +3 -0
  44. package/dist/host/logger.js +18 -0
  45. package/dist/host/main.d.ts +3 -0
  46. package/dist/host/main.js +201 -0
  47. package/dist/host/stack-config.d.ts +72 -0
  48. package/dist/host/stack-config.js +193 -0
  49. package/dist/index.d.ts +7 -2
  50. package/dist/index.js +4 -2
  51. package/dist/infra/database.d.ts +16 -0
  52. package/dist/infra/database.js +31 -0
  53. package/dist/infra/execution.d.ts +39 -0
  54. package/dist/infra/execution.js +68 -0
  55. package/dist/infra/index.d.ts +42 -0
  56. package/dist/infra/index.js +56 -0
  57. package/dist/infra/readiness.d.ts +33 -0
  58. package/dist/infra/readiness.js +53 -0
  59. package/dist/infra/streams.d.ts +17 -0
  60. package/dist/infra/streams.js +33 -0
  61. package/dist/infra/workers.d.ts +39 -0
  62. package/dist/infra/workers.js +31 -0
  63. package/dist/mcp/connect.d.ts +1 -0
  64. package/dist/mcp/connect.js +2 -0
  65. package/dist/mcp/pool.d.ts +3 -1
  66. package/dist/mcp/pool.js +2 -1
  67. package/dist/mcp/stdio.d.ts +2 -0
  68. package/dist/mcp/stdio.js +3 -1
  69. package/dist/model/auth-store.d.ts +1 -1
  70. package/dist/model/auth-store.js +2 -2
  71. package/dist/model/models.d.ts +4 -0
  72. package/dist/model/models.js +22 -26
  73. package/dist/model/pi-model.d.ts +2 -2
  74. package/dist/model/pi-model.js +6 -6
  75. package/dist/model/settings.d.ts +5 -1
  76. package/dist/model/settings.js +6 -2
  77. package/dist/sandbox/manager.d.ts +26 -8
  78. package/dist/sandbox/manager.js +95 -47
  79. package/dist/sandbox/select.d.ts +5 -4
  80. package/dist/sandbox/select.js +18 -13
  81. package/dist/sandbox/share.d.ts +23 -0
  82. package/dist/sandbox/share.js +58 -0
  83. package/dist/sandbox/types.d.ts +2 -2
  84. package/dist/store/canonical.d.ts +5 -0
  85. package/dist/store/canonical.js +17 -0
  86. package/dist/store/cursor.d.ts +9 -0
  87. package/dist/store/cursor.js +18 -0
  88. package/dist/store/memory.d.ts +57 -0
  89. package/dist/store/memory.js +643 -0
  90. package/dist/store/ownership.d.ts +19 -0
  91. package/dist/store/ownership.js +33 -0
  92. package/dist/store/postgres/connect.d.ts +25 -0
  93. package/dist/store/postgres/connect.js +25 -0
  94. package/dist/store/postgres/locking.d.ts +16 -0
  95. package/dist/store/postgres/locking.js +22 -0
  96. package/dist/store/postgres/migrations/001_initial.d.ts +27 -0
  97. package/dist/store/postgres/migrations/001_initial.js +196 -0
  98. package/dist/store/postgres/migrations/index.d.ts +49 -0
  99. package/dist/store/postgres/migrations/index.js +75 -0
  100. package/dist/store/postgres/names.d.ts +11 -0
  101. package/dist/store/postgres/names.js +42 -0
  102. package/dist/store/postgres/store.d.ts +11 -0
  103. package/dist/store/postgres/store.js +790 -0
  104. package/dist/store/postgres/tenants.d.ts +90 -0
  105. package/dist/store/postgres/tenants.js +207 -0
  106. package/dist/store/types.d.ts +440 -0
  107. package/dist/store/types.js +10 -0
  108. package/dist/streams/basin.d.ts +23 -0
  109. package/dist/streams/basin.js +51 -0
  110. package/dist/streams/memory.d.ts +25 -0
  111. package/dist/streams/memory.js +124 -0
  112. package/dist/streams/relay.d.ts +37 -0
  113. package/dist/streams/relay.js +254 -0
  114. package/dist/streams/types.d.ts +115 -0
  115. package/dist/streams/types.js +86 -0
  116. package/dist/tenant/actions.d.ts +153 -0
  117. package/dist/tenant/actions.js +191 -0
  118. package/dist/tenant/advance.d.ts +15 -0
  119. package/dist/tenant/advance.js +439 -0
  120. package/dist/tenant/auth.d.ts +11 -0
  121. package/dist/tenant/auth.js +34 -0
  122. package/dist/tenant/commands.d.ts +3 -0
  123. package/dist/tenant/commands.js +384 -0
  124. package/dist/tenant/context.d.ts +140 -0
  125. package/dist/tenant/context.js +40 -0
  126. package/dist/tenant/effects.d.ts +20 -0
  127. package/dist/tenant/effects.js +539 -0
  128. package/dist/tenant/envelope.d.ts +7 -0
  129. package/dist/tenant/envelope.js +22 -0
  130. package/dist/tenant/ephemeral.d.ts +42 -0
  131. package/dist/tenant/ephemeral.js +224 -0
  132. package/dist/tenant/http.d.ts +32 -0
  133. package/dist/tenant/http.js +51 -0
  134. package/dist/tenant/live.d.ts +92 -0
  135. package/dist/tenant/live.js +290 -0
  136. package/dist/tenant/logger.d.ts +9 -0
  137. package/dist/tenant/logger.js +25 -0
  138. package/dist/tenant/model-setting.d.ts +16 -0
  139. package/dist/tenant/model-setting.js +12 -0
  140. package/dist/tenant/module.d.ts +38 -0
  141. package/dist/tenant/module.js +319 -0
  142. package/dist/tenant/paths.d.ts +28 -0
  143. package/dist/tenant/paths.js +91 -0
  144. package/dist/tenant/pool.d.ts +10 -0
  145. package/dist/tenant/pool.js +54 -0
  146. package/dist/tenant/principals.d.ts +13 -0
  147. package/dist/tenant/principals.js +37 -0
  148. package/dist/tenant/quarantine-error.d.ts +6 -0
  149. package/dist/tenant/quarantine-error.js +9 -0
  150. package/dist/tenant/quarantine.d.ts +27 -0
  151. package/dist/tenant/quarantine.js +72 -0
  152. package/dist/tenant/reset.d.ts +22 -0
  153. package/dist/tenant/reset.js +81 -0
  154. package/dist/tenant/routes-tenant.d.ts +12 -0
  155. package/dist/tenant/routes-tenant.js +126 -0
  156. package/dist/tenant/routes.d.ts +9 -0
  157. package/dist/tenant/routes.js +142 -0
  158. package/dist/tenant/runtime.d.ts +81 -0
  159. package/dist/tenant/runtime.js +298 -0
  160. package/dist/tenant/scheduler.d.ts +25 -0
  161. package/dist/tenant/scheduler.js +84 -0
  162. package/dist/tenant/session.d.ts +60 -0
  163. package/dist/tenant/session.js +94 -0
  164. package/dist/tenant/sessions.d.ts +34 -0
  165. package/dist/tenant/sessions.js +149 -0
  166. package/dist/tenant/status.d.ts +37 -0
  167. package/dist/tenant/status.js +130 -0
  168. package/dist/tenant/store-memory.d.ts +16 -0
  169. package/dist/tenant/store-memory.js +111 -0
  170. package/dist/tenant/store-pg.d.ts +15 -0
  171. package/dist/tenant/store-pg.js +140 -0
  172. package/dist/tenant/streams.d.ts +126 -0
  173. package/dist/tenant/streams.js +419 -0
  174. package/dist/tenant/sweep.d.ts +15 -0
  175. package/dist/tenant/sweep.js +122 -0
  176. package/dist/tenant/types.d.ts +205 -0
  177. package/dist/tenant/types.js +23 -0
  178. package/dist/tenant/worker.d.ts +84 -0
  179. package/dist/tenant/worker.js +189 -0
  180. package/dist/vault/kek.d.ts +5 -2
  181. package/dist/vault/kek.js +7 -10
  182. package/dist/vault/service.d.ts +37 -31
  183. package/dist/vault/service.js +439 -359
  184. package/dist/version.d.ts +1 -1
  185. package/dist/version.js +1 -1
  186. package/package.json +14 -11
  187. package/dist/adapters/sandbox/microsandbox.d.ts +0 -2
  188. package/dist/adapters/sandbox/microsandbox.js +0 -238
  189. package/dist/core/main.js +0 -35
  190. package/dist/core/store.d.ts +0 -30
  191. package/dist/core/store.js +0 -202
  192. /package/dist/{core/main.d.ts → host/config.js} +0 -0
@@ -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 "../core/store.js";
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
- db;
12
- tx;
72
+ store;
13
73
  kek;
14
74
  fetchImpl;
15
- constructor(db, tx, kek, fetchImpl) {
16
- this.db = db;
17
- this.tx = tx;
18
- this.kek = kek;
19
- this.fetchImpl = fetchImpl;
20
- }
21
- createVault(body) {
22
- return this.tx(() => this.replay(`create-vault:${body.idempotencyKey}`, body, () => {
23
- const now = new Date().toISOString();
24
- const id = randomUUID();
25
- this.db
26
- .prepare(`INSERT INTO vaults(id,name,owner_user_id,metadata_json,created_at) VALUES(?,?,?,?,?)`)
27
- .run(id, body.name, body.ownerUserId, body.metadata ? JSON.stringify(body.metadata) : null, now);
28
- const info = this.vaultInfo(id);
29
- this.audit({
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 info;
98
+ return vaultInfoOf(row);
36
99
  }));
37
100
  }
38
- listVaults(ownerUserId) {
39
- const rows = this.db
40
- .prepare(`SELECT id FROM vaults WHERE owner_user_id=? AND scope='user' ORDER BY created_at, id`)
41
- .all(ownerUserId);
42
- return rows.map((row) => this.vaultInfo(row.id));
43
- }
44
- getVault(id) {
45
- return this.vaultInfo(id);
46
- }
47
- deleteVault(id) {
48
- return this.tx(() => {
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
- this.db.prepare(`DELETE FROM vault_credentials WHERE vault_id=?`).run(id);
63
- this.db.prepare(`DELETE FROM vaults WHERE id=?`).run(id);
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
- return this.tx(() => this.replay(`create-credential:${vaultId}:${body.idempotencyKey}`, body, () => {
75
- this.vaultInfo(vaultId);
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 payload = payloadFromCreate(body);
79
- this.insertCredential({
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: body.auth.type,
84
- url,
141
+ type,
142
+ bindingJson: JSON.stringify({ url }),
85
143
  expiresAt: expiresFromCreate(body),
86
- payload,
144
+ createdAt: new Date().toISOString(),
87
145
  rotatedAt: null,
88
- });
89
- this.audit({
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 this.credentialInfo(vaultId, id);
156
+ return credentialInfoOf(row);
97
157
  }));
98
158
  }
99
- listCredentials(vaultId) {
100
- this.vaultInfo(vaultId);
101
- const rows = this.db
102
- .prepare(`SELECT id FROM vault_credentials WHERE vault_id=? ORDER BY created_at, id`)
103
- .all(vaultId);
104
- return rows.map((row) => this.credentialInfo(vaultId, row.id));
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.credentialInfo(vaultId, id);
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
- return this.tx(() => this.replay(`rotate:${vaultId}:${id}:${body.idempotencyKey}`, body, () => {
111
- const row = this.credentialRow(vaultId, id);
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 current = this.readPayload(row);
115
- const next = { ...current };
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 this.credentialInfo(vaultId, id);
195
+ return credentialInfoOf(updated);
135
196
  }));
136
197
  }
137
- deleteCredential(vaultId, id) {
138
- return this.tx(() => {
139
- this.credentialRow(vaultId, id);
140
- this.db
141
- .prepare(`DELETE FROM vault_credentials WHERE id=? AND vault_id=?`)
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
- assertAttachment(ownerUserId, vaultIds, selections) {
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 = this.vaultRow(id);
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.owner_user_id !== ownerUserId)
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 = this.db
169
- .prepare(`SELECT vault_id FROM vault_credentials WHERE id=?`)
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
- recordAttachment(sessionId, vaultIds) {
176
- this.audit({
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 missing = input.vaultIds.filter((id) => !this.vaultRow(id));
187
- if (missing.length > 0) {
188
- this.audit({
189
- actor: "host",
190
- action: "use",
191
- sessionId: input.sessionId,
192
- target: url,
193
- outcome: "refused",
194
- });
195
- return {
196
- status: "refused",
197
- url,
198
- credentialIds: [],
199
- reason: "vault_missing",
200
- };
201
- }
202
- const matches = input.vaultIds
203
- .flatMap((vaultId) => this.rowsForVault(vaultId))
204
- .filter((row) => JSON.parse(row.binding_json).url === url)
205
- .sort((a, b) => a.id.localeCompare(b.id));
206
- const selection = input.serverName
207
- ? input.credentialSelections.find((item) => item.serverName === input.serverName)
208
- : undefined;
209
- if (matches.length === 0)
210
- return { status: "unauthenticated", url, headers: {} };
211
- const chosen = choose(matches, selection);
212
- if (chosen.kind === "refused") {
213
- this.audit({
214
- actor: "host",
215
- action: "use",
216
- sessionId: input.sessionId,
217
- target: url,
218
- outcome: "refused",
219
- });
220
- return {
221
- status: "refused",
222
- url,
223
- credentialIds: chosen.credentialIds,
224
- reason: chosen.reason,
225
- };
226
- }
227
- let row = chosen.row;
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 = this.readPayload(row);
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.vault_id,
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.expires_at)) {
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.vault_id,
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
- getHostModel() {
283
- const row = this.activeHostCredentialRow();
284
- if (!row)
285
- return { configured: false };
286
- const binding = modelBinding(row);
287
- return {
288
- configured: true,
289
- provider: binding.provider,
290
- model: binding.model,
291
- authType: binding.authType,
292
- ...(binding.baseUrl ? { baseUrl: binding.baseUrl } : {}),
293
- };
294
- }
295
- listHostProviders() {
296
- const active = this.activeProviderId();
297
- const catalog = new Map(hostModelCatalog().providers.map((provider) => [provider.id, provider.name]));
298
- const providers = this.hostModelRows().map((row) => {
299
- const binding = modelBinding(row);
300
- return {
301
- id: binding.provider,
302
- name: catalog.get(binding.provider) ??
303
- (binding.provider === "custom"
304
- ? "Custom OpenAI-compatible"
305
- : binding.provider),
306
- model: binding.model,
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
- return this.tx(() => this.replay(`host-model:${body.idempotencyKey}`, body, () => {
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.getHostModel();
412
+ return this.hostModelView(t);
345
413
  }));
346
414
  }
347
- selectHostModel(body) {
348
- return this.tx(() => this.replay(`host-model-select:${body.idempotencyKey}`, body, () => {
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 = this.readHostPayload(row, binding);
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.getHostModel();
442
+ return this.hostModelView(t);
374
443
  }));
375
444
  }
376
- readHostModel() {
377
- const row = this.activeHostCredentialRow();
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.readHostPayload(row, binding);
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.tx(() => {
399
- const row = this.activeHostCredentialRow();
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 aad = hostModelAad(binding.provider, binding.model);
410
- const sealed = encryptSecret(this.kek(), aad, Buffer.from(JSON.stringify(payload), "utf8"));
411
- this.db
412
- .prepare(`UPDATE vault_credentials
413
- SET type='model', binding_json=?, kek_id=?, nonce=?, ciphertext=?, wrapped_dek=?, rotated_at=?
414
- WHERE id=?`)
415
- .run(JSON.stringify({ ...binding, authType: credential.type }), sealed.kekId, sealed.nonce, sealed.ciphertext, sealed.wrappedDek, new Date().toISOString(), row.id);
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
- reject(route) {
419
- this.audit({
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
- async refresh(row, payload, sessionId, url) {
427
- const failRefresh = () => {
428
- this.audit({
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.vault_id,
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
- const next = {
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.vault_id, row.id);
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.vault_id,
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 = this.db
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.body_hash !== hash)
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
- this.db
520
- .prepare(`INSERT INTO vault_idempotency(id, body_hash, response) VALUES(?,?,?)`)
521
- .run(key, hash, JSON.stringify(created));
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
- insertCredential(input) {
525
- const aad = credentialAad(input.vaultId, input.id, input.type, input.url);
526
- const sealed = encryptSecret(this.kek(), aad, Buffer.from(JSON.stringify(input.payload), "utf8"));
527
- this.db
528
- .prepare(`INSERT INTO vault_credentials(
529
- id, vault_id, name, type, binding_json, expires_at, created_at, rotated_at,
530
- kek_id, nonce, ciphertext, wrapped_dek
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 = this.vaultRow(id);
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 = this.vaultRow(HOST_VAULT_ID);
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
- this.db
581
- .prepare(`INSERT INTO vaults(id,name,owner_user_id,metadata_json,created_at,scope) VALUES(?,?,?,?,?,?)`)
582
- .run(HOST_VAULT_ID, "Host", "host", null, new Date().toISOString(), "host");
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 aad = hostModelAad(input.provider, input.model);
609
- const sealed = encryptSecret(this.kek(), aad, Buffer.from(JSON.stringify(input.payload), "utf8"));
610
- this.db
611
- .prepare(`INSERT INTO vault_credentials(
612
- id, vault_id, name, type, binding_json, expires_at, created_at, rotated_at,
613
- kek_id, nonce, ciphertext, wrapped_dek
614
- ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`)
615
- .run(input.id, HOST_VAULT_ID, input.provider, "model", JSON.stringify({
616
- provider: input.provider,
617
- model: input.model,
618
- ...(input.baseUrl ? { baseUrl: input.baseUrl } : {}),
619
- authType: input.authType,
620
- }), null, new Date().toISOString(), null, sealed.kekId, sealed.nonce, sealed.ciphertext, sealed.wrappedDek);
621
- }
622
- readHostPayload(row, binding) {
623
- const plaintext = decryptSecret(this.kek(), hostModelAad(binding.provider, binding.model), Buffer.from(row.nonce), Buffer.from(row.ciphertext), Buffer.from(row.wrapped_dek), row.kek_id);
624
- try {
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
- hostModelRows() {
632
- return this.db
633
- .prepare(`SELECT id, vault_id, name, type, binding_json, expires_at, created_at, rotated_at,
634
- kek_id, nonce, ciphertext, wrapped_dek
635
- FROM vault_credentials WHERE vault_id=? AND type='model'
636
- ORDER BY created_at, id`)
637
- .all(HOST_VAULT_ID).filter((row) => {
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 = this.vaultRow(HOST_VAULT_ID);
661
- if (vault?.metadata_json) {
706
+ async activeProviderId(t) {
707
+ const vault = await t.getVault(HOST_VAULT_ID);
708
+ if (vault?.metadataJson) {
709
+ let activeProvider;
662
710
  try {
663
- const metadata = JSON.parse(vault.metadata_json);
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
- this.db
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
- this.db
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
- credentialInfo(vaultId, id) {
700
- const row = this.credentialRow(vaultId, id);
701
- return {
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
- rowsForVault(vaultId) {
723
- return this.db
724
- .prepare(`SELECT id, vault_id, name, type, binding_json, expires_at, created_at, rotated_at,
725
- kek_id, nonce, ciphertext, wrapped_dek
726
- FROM vault_credentials WHERE vault_id=?`)
727
- .all(vaultId);
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
- audit(entry) {
730
- this.db
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.binding_json);
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;