@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.
Files changed (194) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +53 -51
  3. package/dist/adapters/execution/restate.d.ts +130 -0
  4. package/dist/adapters/execution/restate.js +442 -0
  5. package/dist/adapters/sandbox/virtual.js +1 -1
  6. package/dist/adapters/streams/s2.d.ts +18 -0
  7. package/dist/adapters/streams/s2.js +361 -0
  8. package/dist/core/executors.d.ts +4 -1
  9. package/dist/core/executors.js +20 -1
  10. package/dist/core/flow-host.d.ts +93 -132
  11. package/dist/core/flow-host.js +282 -547
  12. package/dist/core/sandbox-routes.d.ts +13 -4
  13. package/dist/core/sandbox-routes.js +14 -12
  14. package/dist/execution/memory.d.ts +54 -0
  15. package/dist/execution/memory.js +217 -0
  16. package/dist/execution/types.d.ts +125 -0
  17. package/dist/execution/types.js +52 -0
  18. package/dist/host/config-for.d.ts +2 -1
  19. package/dist/host/config-for.js +3 -21
  20. package/dist/host/config.d.ts +0 -8
  21. package/dist/host/create-host.d.ts +36 -1
  22. package/dist/host/create-host.js +51 -24
  23. package/dist/host/execution.d.ts +61 -0
  24. package/dist/host/execution.js +69 -0
  25. package/dist/host/http.d.ts +4 -0
  26. package/dist/host/http.js +6 -0
  27. package/dist/host/index.d.ts +3 -1
  28. package/dist/host/index.js +2 -0
  29. package/dist/host/main.js +106 -47
  30. package/dist/host/stack-config.d.ts +72 -0
  31. package/dist/host/stack-config.js +193 -0
  32. package/dist/index.d.ts +1 -1
  33. package/dist/infra/database.d.ts +16 -0
  34. package/dist/infra/database.js +31 -0
  35. package/dist/infra/execution.d.ts +39 -0
  36. package/dist/infra/execution.js +68 -0
  37. package/dist/infra/index.d.ts +42 -0
  38. package/dist/infra/index.js +56 -0
  39. package/dist/infra/readiness.d.ts +33 -0
  40. package/dist/infra/readiness.js +53 -0
  41. package/dist/infra/streams.d.ts +17 -0
  42. package/dist/infra/streams.js +33 -0
  43. package/dist/infra/workers.d.ts +39 -0
  44. package/dist/infra/workers.js +31 -0
  45. package/dist/model/pi-model.d.ts +2 -2
  46. package/dist/model/pi-model.js +5 -5
  47. package/dist/sandbox/manager.d.ts +26 -8
  48. package/dist/sandbox/manager.js +95 -47
  49. package/dist/sandbox/select.d.ts +1 -1
  50. package/dist/sandbox/select.js +4 -5
  51. package/dist/sandbox/types.d.ts +2 -2
  52. package/dist/store/canonical.d.ts +5 -0
  53. package/dist/store/canonical.js +17 -0
  54. package/dist/store/cursor.d.ts +9 -0
  55. package/dist/store/cursor.js +18 -0
  56. package/dist/store/memory.d.ts +57 -0
  57. package/dist/store/memory.js +645 -0
  58. package/dist/store/ownership.d.ts +19 -0
  59. package/dist/store/ownership.js +33 -0
  60. package/dist/store/postgres/connect.d.ts +25 -0
  61. package/dist/store/postgres/connect.js +25 -0
  62. package/dist/store/postgres/locking.d.ts +16 -0
  63. package/dist/store/postgres/locking.js +22 -0
  64. package/dist/store/postgres/migrations/001_initial.d.ts +27 -0
  65. package/dist/store/postgres/migrations/001_initial.js +196 -0
  66. package/dist/store/postgres/migrations/002_session_owner.d.ts +6 -0
  67. package/dist/store/postgres/migrations/002_session_owner.js +13 -0
  68. package/dist/store/postgres/migrations/index.d.ts +49 -0
  69. package/dist/store/postgres/migrations/index.js +76 -0
  70. package/dist/store/postgres/names.d.ts +11 -0
  71. package/dist/store/postgres/names.js +42 -0
  72. package/dist/store/postgres/store.d.ts +11 -0
  73. package/dist/store/postgres/store.js +797 -0
  74. package/dist/store/postgres/tenants.d.ts +90 -0
  75. package/dist/store/postgres/tenants.js +214 -0
  76. package/dist/store/types.d.ts +442 -0
  77. package/dist/store/types.js +10 -0
  78. package/dist/streams/basin.d.ts +23 -0
  79. package/dist/streams/basin.js +51 -0
  80. package/dist/streams/memory.d.ts +25 -0
  81. package/dist/streams/memory.js +124 -0
  82. package/dist/streams/relay.d.ts +37 -0
  83. package/dist/streams/relay.js +254 -0
  84. package/dist/streams/types.d.ts +115 -0
  85. package/dist/streams/types.js +86 -0
  86. package/dist/tenant/actions.d.ts +153 -0
  87. package/dist/tenant/actions.js +191 -0
  88. package/dist/tenant/advance.d.ts +15 -0
  89. package/dist/tenant/advance.js +439 -0
  90. package/dist/tenant/auth.d.ts +32 -0
  91. package/dist/tenant/auth.js +169 -0
  92. package/dist/tenant/commands.d.ts +3 -0
  93. package/dist/tenant/commands.js +393 -0
  94. package/dist/tenant/context.d.ts +147 -0
  95. package/dist/tenant/context.js +48 -0
  96. package/dist/tenant/effects.d.ts +20 -0
  97. package/dist/tenant/effects.js +564 -0
  98. package/dist/tenant/envelope.d.ts +0 -3
  99. package/dist/tenant/envelope.js +2 -34
  100. package/dist/tenant/ephemeral.d.ts +15 -4
  101. package/dist/tenant/ephemeral.js +106 -38
  102. package/dist/tenant/http.d.ts +38 -0
  103. package/dist/tenant/http.js +53 -0
  104. package/dist/tenant/live.d.ts +92 -0
  105. package/dist/tenant/live.js +290 -0
  106. package/dist/tenant/model-setting.d.ts +16 -0
  107. package/dist/tenant/model-setting.js +12 -0
  108. package/dist/tenant/module.d.ts +29 -8
  109. package/dist/tenant/module.js +165 -158
  110. package/dist/tenant/paths.d.ts +12 -8
  111. package/dist/tenant/paths.js +38 -12
  112. package/dist/tenant/principals.d.ts +12 -13
  113. package/dist/tenant/principals.js +50 -38
  114. package/dist/tenant/quarantine-error.d.ts +1 -1
  115. package/dist/tenant/quarantine-error.js +2 -8
  116. package/dist/tenant/quarantine.d.ts +0 -8
  117. package/dist/tenant/quarantine.js +8 -30
  118. package/dist/tenant/reset.d.ts +7 -6
  119. package/dist/tenant/reset.js +7 -51
  120. package/dist/tenant/routes-tenant.d.ts +12 -0
  121. package/dist/tenant/routes-tenant.js +137 -0
  122. package/dist/tenant/routes.d.ts +9 -0
  123. package/dist/tenant/routes.js +151 -0
  124. package/dist/tenant/runtime.d.ts +59 -58
  125. package/dist/tenant/runtime.js +243 -2201
  126. package/dist/tenant/scheduler.d.ts +25 -0
  127. package/dist/tenant/scheduler.js +84 -0
  128. package/dist/tenant/session.d.ts +60 -0
  129. package/dist/tenant/session.js +94 -0
  130. package/dist/tenant/sessions.d.ts +40 -0
  131. package/dist/tenant/sessions.js +163 -0
  132. package/dist/tenant/status.d.ts +13 -4
  133. package/dist/tenant/status.js +76 -41
  134. package/dist/tenant/store-memory.d.ts +9 -12
  135. package/dist/tenant/store-memory.js +79 -68
  136. package/dist/tenant/store-pg.d.ts +15 -0
  137. package/dist/tenant/store-pg.js +140 -0
  138. package/dist/tenant/streams.d.ts +126 -0
  139. package/dist/tenant/streams.js +419 -0
  140. package/dist/tenant/sweep.d.ts +15 -0
  141. package/dist/tenant/sweep.js +122 -0
  142. package/dist/tenant/transcript.d.ts +104 -0
  143. package/dist/tenant/transcript.js +58 -0
  144. package/dist/tenant/types.d.ts +101 -15
  145. package/dist/tenant/types.js +23 -1
  146. package/dist/tenant/worker.d.ts +84 -0
  147. package/dist/tenant/worker.js +189 -0
  148. package/dist/vault/service.d.ts +44 -31
  149. package/dist/vault/service.js +454 -360
  150. package/dist/version.d.ts +1 -1
  151. package/dist/version.js +1 -1
  152. package/package.json +15 -14
  153. package/dist/adapters/sandbox/microsandbox.d.ts +0 -2
  154. package/dist/adapters/sandbox/microsandbox.js +0 -238
  155. package/dist/core/store.d.ts +0 -32
  156. package/dist/core/store.js +0 -143
  157. package/dist/launcher/commands.d.ts +0 -24
  158. package/dist/launcher/commands.js +0 -260
  159. package/dist/launcher/ensure-config.d.ts +0 -16
  160. package/dist/launcher/ensure-config.js +0 -79
  161. package/dist/launcher/errors.d.ts +0 -12
  162. package/dist/launcher/errors.js +0 -22
  163. package/dist/launcher/host-config.d.ts +0 -17
  164. package/dist/launcher/host-config.js +0 -64
  165. package/dist/launcher/host-state.d.ts +0 -17
  166. package/dist/launcher/host-state.js +0 -61
  167. package/dist/launcher/lifecycle.d.ts +0 -66
  168. package/dist/launcher/lifecycle.js +0 -407
  169. package/dist/launcher/locks.d.ts +0 -26
  170. package/dist/launcher/locks.js +0 -117
  171. package/dist/launcher/logs.d.ts +0 -18
  172. package/dist/launcher/logs.js +0 -117
  173. package/dist/launcher/main.d.ts +0 -2
  174. package/dist/launcher/main.js +0 -66
  175. package/dist/launcher/output.d.ts +0 -11
  176. package/dist/launcher/output.js +0 -68
  177. package/dist/launcher/paths.d.ts +0 -20
  178. package/dist/launcher/paths.js +0 -36
  179. package/dist/launcher/probe.d.ts +0 -23
  180. package/dist/launcher/probe.js +0 -72
  181. package/dist/launcher/protocol.d.ts +0 -62
  182. package/dist/launcher/protocol.js +0 -1
  183. package/dist/launcher/schema-guard.d.ts +0 -6
  184. package/dist/launcher/schema-guard.js +0 -36
  185. package/dist/launcher/spawn.d.ts +0 -22
  186. package/dist/launcher/spawn.js +0 -85
  187. package/dist/launcher/status.d.ts +0 -6
  188. package/dist/launcher/status.js +0 -65
  189. package/dist/tenant/migration.d.ts +0 -16
  190. package/dist/tenant/migration.js +0 -116
  191. package/dist/tenant/schema.d.ts +0 -23
  192. package/dist/tenant/schema.js +0 -151
  193. package/dist/tenant/store-fs.d.ts +0 -16
  194. package/dist/tenant/store-fs.js +0 -205
@@ -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 "../core/store.js";
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
- db;
12
- tx;
73
+ store;
13
74
  kek;
14
75
  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({
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 info;
99
+ return vaultInfoOf(row);
36
100
  }));
37
101
  }
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({
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
- 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({
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
- return this.tx(() => this.replay(`create-credential:${vaultId}:${body.idempotencyKey}`, body, () => {
75
- this.vaultInfo(vaultId);
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 payload = payloadFromCreate(body);
79
- this.insertCredential({
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: body.auth.type,
84
- url,
150
+ type,
151
+ bindingJson: JSON.stringify({ url }),
85
152
  expiresAt: expiresFromCreate(body),
86
- payload,
153
+ createdAt: new Date().toISOString(),
87
154
  rotatedAt: null,
88
- });
89
- this.audit({
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 this.credentialInfo(vaultId, id);
165
+ return credentialInfoOf(row);
97
166
  }));
98
167
  }
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));
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.credentialInfo(vaultId, id);
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
- return this.tx(() => this.replay(`rotate:${vaultId}:${id}:${body.idempotencyKey}`, body, () => {
111
- const row = this.credentialRow(vaultId, id);
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 current = this.readPayload(row);
115
- const next = { ...current };
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 this.credentialInfo(vaultId, id);
204
+ return credentialInfoOf(updated);
135
205
  }));
136
206
  }
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({
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
- assertAttachment(ownerUserId, vaultIds, selections) {
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 = this.vaultRow(id);
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.owner_user_id !== ownerUserId)
165
- throw new VaultError(403, "Vault belongs to another user");
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 = this.db
169
- .prepare(`SELECT vault_id FROM vault_credentials WHERE id=?`)
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
- recordAttachment(sessionId, vaultIds) {
176
- this.audit({
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 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;
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 = this.readPayload(row);
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.vault_id,
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.expires_at)) {
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.vault_id,
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
- 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
- };
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
- return this.tx(() => this.replay(`host-model:${body.idempotencyKey}`, body, () => {
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.getHostModel();
426
+ return this.hostModelView(t);
345
427
  }));
346
428
  }
347
- selectHostModel(body) {
348
- return this.tx(() => this.replay(`host-model-select:${body.idempotencyKey}`, body, () => {
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 = this.readHostPayload(row, binding);
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.getHostModel();
456
+ return this.hostModelView(t);
374
457
  }));
375
458
  }
376
- readHostModel() {
377
- const row = this.activeHostCredentialRow();
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.readHostPayload(row, binding);
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.tx(() => {
399
- const row = this.activeHostCredentialRow();
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 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);
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
- reject(route) {
419
- this.audit({
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
- async refresh(row, payload, sessionId, url) {
427
- const failRefresh = () => {
428
- this.audit({
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.vault_id,
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
- const next = {
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.vault_id, row.id);
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.vault_id,
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 = this.db
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.body_hash !== hash)
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
- this.db
520
- .prepare(`INSERT INTO vault_idempotency(id, body_hash, response) VALUES(?,?,?)`)
521
- .run(key, hash, JSON.stringify(created));
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
- 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
- }
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 = this.vaultRow(id);
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
- 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);
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
- 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");
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 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
- }
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
- 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) => {
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 = this.vaultRow(HOST_VAULT_ID);
661
- if (vault?.metadata_json) {
720
+ async activeProviderId(t) {
721
+ const vault = await t.getVault(HOST_VAULT_ID);
722
+ if (vault?.metadataJson) {
723
+ let activeProvider;
662
724
  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;
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
- this.db
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
- this.db
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
- 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")
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
- 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);
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
- 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);
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.binding_json);
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;