@nylorun/runtime 0.14.0-beta → 0.15.1-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 (198) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +82 -40
  3. package/dist/adapters/execution/restate.js +2 -2
  4. package/dist/adapters/replication/pgoutput.d.ts +5 -0
  5. package/dist/adapters/replication/pgoutput.js +4 -3
  6. package/dist/admin-openapi.json +185 -686
  7. package/dist/api/components.d.ts +308 -68
  8. package/dist/api/components.js +4 -5
  9. package/dist/api/gate/keys.d.ts +14 -0
  10. package/dist/api/gate/keys.js +59 -0
  11. package/dist/api/gate/routes.d.ts +34 -0
  12. package/dist/api/gate/routes.js +235 -0
  13. package/dist/api/http/define.js +0 -8
  14. package/dist/api/http/routes/access.js +25 -18
  15. package/dist/api/http/routes/tenant.d.ts +2 -1
  16. package/dist/api/http/routes/tenant.js +77 -5
  17. package/dist/api/http/routes/vaults.js +2 -2
  18. package/dist/api/openapi.js +6 -7
  19. package/dist/gates/contract.d.ts +51 -0
  20. package/dist/gates/contract.js +37 -0
  21. package/dist/gates/handler.d.ts +21 -0
  22. package/dist/gates/handler.js +55 -0
  23. package/dist/gates/http-client.d.ts +12 -0
  24. package/dist/gates/http-client.js +160 -0
  25. package/dist/gates/in-process.d.ts +32 -0
  26. package/dist/gates/in-process.js +42 -0
  27. package/dist/gates/inflight.d.ts +39 -0
  28. package/dist/gates/inflight.js +70 -0
  29. package/dist/gates/mcp-handler.d.ts +35 -0
  30. package/dist/gates/mcp-handler.js +142 -0
  31. package/dist/gates/meter.d.ts +24 -0
  32. package/dist/gates/meter.js +165 -0
  33. package/dist/gates/model-gate.d.ts +45 -0
  34. package/dist/gates/model-gate.js +1 -0
  35. package/dist/gates/tenant-vaults.d.ts +64 -0
  36. package/dist/gates/tenant-vaults.js +94 -0
  37. package/dist/gates/tool-calls.d.ts +51 -0
  38. package/dist/gates/tool-calls.js +64 -0
  39. package/dist/gates/tool-client.d.ts +12 -0
  40. package/dist/gates/tool-client.js +201 -0
  41. package/dist/gates/tool-contract.d.ts +116 -0
  42. package/dist/gates/tool-contract.js +64 -0
  43. package/dist/gates/tool-gate.d.ts +45 -0
  44. package/dist/gates/tool-gate.js +10 -0
  45. package/dist/host/admin-api.d.ts +7 -6
  46. package/dist/host/admin-api.js +9 -113
  47. package/dist/host/app.d.ts +1 -1
  48. package/dist/host/app.js +20 -24
  49. package/dist/host/config-for.d.ts +1 -1
  50. package/dist/host/config-for.js +2 -2
  51. package/dist/host/create-host.d.ts +5 -5
  52. package/dist/host/create-host.js +5 -15
  53. package/dist/host/execution.d.ts +20 -18
  54. package/dist/host/execution.js +2 -2
  55. package/dist/host/gates.d.ts +58 -0
  56. package/dist/host/gates.js +127 -0
  57. package/dist/host/http.d.ts +6 -1
  58. package/dist/host/http.js +17 -0
  59. package/dist/host/index.d.ts +1 -1
  60. package/dist/host/main.js +151 -71
  61. package/dist/host/stack-config.d.ts +71 -19
  62. package/dist/host/stack-config.js +192 -18
  63. package/dist/infra/database.d.ts +2 -2
  64. package/dist/infra/execution.d.ts +5 -5
  65. package/dist/infra/execution.js +8 -6
  66. package/dist/infra/index.d.ts +1 -1
  67. package/dist/infra/workers.d.ts +17 -17
  68. package/dist/infra/workers.js +9 -9
  69. package/dist/keys/client.d.ts +9 -0
  70. package/dist/keys/client.js +70 -0
  71. package/dist/keys/contract.d.ts +24 -0
  72. package/dist/keys/contract.js +12 -0
  73. package/dist/keys/keys.d.ts +41 -0
  74. package/dist/keys/keys.js +50 -0
  75. package/dist/mcp/connect.d.ts +43 -3
  76. package/dist/mcp/connect.js +12 -5
  77. package/dist/mcp/pool.d.ts +21 -3
  78. package/dist/mcp/pool.js +15 -2
  79. package/dist/openapi.json +888 -695
  80. package/dist/record/append.d.ts +51 -0
  81. package/dist/record/append.js +20 -0
  82. package/dist/{store/event.js → record/envelope.js} +1 -1
  83. package/dist/record/index.d.ts +11 -0
  84. package/dist/record/index.js +11 -0
  85. package/dist/store/postgres/connect.d.ts +15 -12
  86. package/dist/store/postgres/connect.js +24 -12
  87. package/dist/store/postgres/db.d.ts +16 -0
  88. package/dist/store/postgres/db.js +28 -0
  89. package/dist/store/postgres/drizzle/0000_foundation.sql +22 -0
  90. package/dist/store/postgres/drizzle/0001_baseline.sql +255 -0
  91. package/dist/store/postgres/drizzle/0002_stream_relay.sql +4 -0
  92. package/dist/store/postgres/drizzle/0003_tool_crossings.sql +9 -0
  93. package/dist/store/postgres/drizzle/meta/_journal.json +34 -0
  94. package/dist/store/postgres/migrate.d.ts +68 -0
  95. package/dist/store/postgres/migrate.js +183 -0
  96. package/dist/store/postgres/record-writer.d.ts +4 -0
  97. package/dist/store/postgres/record-writer.js +34 -0
  98. package/dist/store/postgres/record.d.ts +2 -8
  99. package/dist/store/postgres/record.js +50 -54
  100. package/dist/store/postgres/schema.d.ts +3096 -0
  101. package/dist/store/postgres/schema.js +363 -0
  102. package/dist/store/postgres/store.d.ts +2 -4
  103. package/dist/store/postgres/store.js +481 -567
  104. package/dist/store/postgres/tenant.d.ts +56 -0
  105. package/dist/store/postgres/tenant.js +154 -0
  106. package/dist/store/types.d.ts +43 -87
  107. package/dist/store/types.js +0 -1
  108. package/dist/streams/control.d.ts +1 -1
  109. package/dist/streams/control.js +6 -2
  110. package/dist/streams/relay/types.d.ts +2 -1
  111. package/dist/streams/relay/types.js +2 -1
  112. package/dist/streams/types.d.ts +5 -0
  113. package/dist/tenant/advance.d.ts +7 -2
  114. package/dist/tenant/advance.js +95 -16
  115. package/dist/tenant/cause.d.ts +25 -0
  116. package/dist/tenant/cause.js +45 -0
  117. package/dist/tenant/commands.js +4 -2
  118. package/dist/tenant/context.d.ts +26 -3
  119. package/dist/tenant/delivery-token.js +17 -18
  120. package/dist/tenant/delivery.d.ts +1 -1
  121. package/dist/tenant/delivery.js +18 -13
  122. package/dist/tenant/effects.d.ts +15 -1
  123. package/dist/tenant/effects.js +93 -17
  124. package/dist/tenant/ephemeral.d.ts +16 -7
  125. package/dist/tenant/ephemeral.js +45 -111
  126. package/dist/tenant/history.d.ts +64 -0
  127. package/dist/tenant/history.js +120 -0
  128. package/dist/tenant/model-setting.d.ts +2 -2
  129. package/dist/tenant/module.d.ts +14 -28
  130. package/dist/tenant/module.js +131 -271
  131. package/dist/tenant/paths.d.ts +11 -16
  132. package/dist/tenant/paths.js +24 -68
  133. package/dist/tenant/pool.d.ts +1 -1
  134. package/dist/tenant/pool.js +1 -1
  135. package/dist/tenant/principals.d.ts +23 -11
  136. package/dist/tenant/principals.js +61 -49
  137. package/dist/tenant/runtime.d.ts +19 -1
  138. package/dist/tenant/runtime.js +40 -26
  139. package/dist/tenant/scheduler.d.ts +4 -2
  140. package/dist/tenant/scheduler.js +4 -4
  141. package/dist/tenant/session-streams.d.ts +2 -0
  142. package/dist/tenant/session-streams.js +8 -3
  143. package/dist/tenant/status.d.ts +3 -0
  144. package/dist/tenant/status.js +1 -1
  145. package/dist/tenant/store-pg.d.ts +9 -6
  146. package/dist/tenant/store-pg.js +60 -125
  147. package/dist/tenant/streams.d.ts +9 -25
  148. package/dist/tenant/streams.js +7 -22
  149. package/dist/tenant/tokens.js +16 -14
  150. package/dist/tenant/types.d.ts +41 -92
  151. package/dist/tenant/types.js +4 -15
  152. package/dist/tenant/worker.js +4 -4
  153. package/dist/vault/host-model.d.ts +31 -0
  154. package/dist/vault/host-model.js +57 -0
  155. package/dist/vault/service.d.ts +26 -15
  156. package/dist/vault/service.js +70 -109
  157. package/dist/version.d.ts +1 -1
  158. package/dist/version.js +1 -1
  159. package/package.json +9 -5
  160. package/dist/store/memory.d.ts +0 -69
  161. package/dist/store/memory.js +0 -810
  162. package/dist/store/postgres/migrations/001_initial.d.ts +0 -27
  163. package/dist/store/postgres/migrations/001_initial.js +0 -196
  164. package/dist/store/postgres/migrations/002_session_owner.d.ts +0 -6
  165. package/dist/store/postgres/migrations/002_session_owner.js +0 -13
  166. package/dist/store/postgres/migrations/003_subject_tokens.d.ts +0 -8
  167. package/dist/store/postgres/migrations/003_subject_tokens.js +0 -42
  168. package/dist/store/postgres/migrations/004_publishable_keys.d.ts +0 -6
  169. package/dist/store/postgres/migrations/004_publishable_keys.js +0 -18
  170. package/dist/store/postgres/migrations/005_action_endpoints.d.ts +0 -6
  171. package/dist/store/postgres/migrations/005_action_endpoints.js +0 -32
  172. package/dist/store/postgres/migrations/006_drop_executors.d.ts +0 -11
  173. package/dist/store/postgres/migrations/006_drop_executors.js +0 -32
  174. package/dist/store/postgres/migrations/007_event_time.d.ts +0 -7
  175. package/dist/store/postgres/migrations/007_event_time.js +0 -14
  176. package/dist/store/postgres/migrations/008_durable_streams.d.ts +0 -13
  177. package/dist/store/postgres/migrations/008_durable_streams.js +0 -35
  178. package/dist/store/postgres/migrations/index.d.ts +0 -53
  179. package/dist/store/postgres/migrations/index.js +0 -96
  180. package/dist/store/postgres/migrations/shared/index.d.ts +0 -31
  181. package/dist/store/postgres/migrations/shared/index.js +0 -92
  182. package/dist/store/postgres/names.d.ts +0 -11
  183. package/dist/store/postgres/names.js +0 -42
  184. package/dist/store/postgres/tenants.d.ts +0 -90
  185. package/dist/store/postgres/tenants.js +0 -228
  186. package/dist/streams/relay/memory.d.ts +0 -69
  187. package/dist/streams/relay/memory.js +0 -176
  188. package/dist/tenant/envelope.d.ts +0 -7
  189. package/dist/tenant/envelope.js +0 -22
  190. package/dist/tenant/quarantine-error.d.ts +0 -6
  191. package/dist/tenant/quarantine-error.js +0 -9
  192. package/dist/tenant/quarantine.d.ts +0 -27
  193. package/dist/tenant/quarantine.js +0 -72
  194. package/dist/tenant/store-memory.d.ts +0 -16
  195. package/dist/tenant/store-memory.js +0 -111
  196. /package/dist/{store → record}/cursor.d.ts +0 -0
  197. /package/dist/{store → record}/cursor.js +0 -0
  198. /package/dist/{store/event.d.ts → record/envelope.d.ts} +0 -0
@@ -0,0 +1,235 @@
1
+ /**
2
+ * The gates service's HTTP routes (blueprint §15, P1.1): the Model Gate's IR endpoint, the Tool
3
+ * Gate's remote MCP and delivery routes (F4.1), and the health and readiness probes. Internal:
4
+ * only the loop calls them, with the stack's gates token, so they are not in the published
5
+ * OpenAPI documents. The wire formats are `gates/contract.ts` and `gates/tool-contract.ts`.
6
+ */
7
+ import { createHash } from "node:crypto";
8
+ import { Hono } from "hono";
9
+ import { bodyLimit } from "hono/body-limit";
10
+ import { isTenantId } from "@nylorun/core/compatibility";
11
+ import { MAX_MODEL_CALL_BYTES, MODEL_CALLS_PATH, ModelCallBodySchema, TENANT_HEADER, } from "../../gates/contract.js";
12
+ import { InflightConflict } from "../../gates/inflight.js";
13
+ import { mountKeysRoutes } from "./keys.js";
14
+ import { DELIVERIES_PATH, DELIVERY_HEADERS, DeliveryBodySchema, MAX_TOOL_BODY_BYTES, MCP_CLOSE_PATH, MCP_CONNECT_PATH, MCP_LIST_PATH, McpListBodySchema, McpServerBodySchema, TOOL_CALLS_PATH, ToolCallBodySchema, } from "../../gates/tool-contract.js";
15
+ import { post } from "../../tenant/outbound.js";
16
+ import { adminKeyMatches } from "../../host/http.js";
17
+ import { canonical } from "../../store/canonical.js";
18
+ const invalid = (message) => ({
19
+ error: { code: "invalid_request", message },
20
+ });
21
+ export function createGatesApp(options) {
22
+ const app = new Hono();
23
+ const maxSize = options.maxBodyBytes ?? MAX_MODEL_CALL_BYTES;
24
+ app.get("/health", (c) => c.json({ status: "ok" }));
25
+ app.get("/ready", async (c) => (await options.ready()) ? c.json({ ready: true }) : c.json({ ready: false }, 503));
26
+ const authorized = async (c, next) => {
27
+ const presented = /^Bearer (.+)$/.exec(c.req.header("authorization") ?? "")?.[1];
28
+ if (!adminKeyMatches(presented, options.token)) {
29
+ const body = {
30
+ error: {
31
+ code: "gate_unauthorized",
32
+ message: "Missing or wrong gates token; the caller and the gateway must share NYLORUN_GATES_TOKEN",
33
+ },
34
+ };
35
+ return c.json(body, 401);
36
+ }
37
+ await next();
38
+ };
39
+ const tenantOf = (c) => {
40
+ const tenantId = c.req.header(TENANT_HEADER);
41
+ return tenantId !== undefined && !isTenantId(tenantId) ? false : tenantId;
42
+ };
43
+ const toolBodies = bodyLimit({
44
+ maxSize: MAX_TOOL_BODY_BYTES,
45
+ onError: (c) => c.json(invalid(`A request body may be at most ${MAX_TOOL_BODY_BYTES} bytes`), 400),
46
+ });
47
+ /** The parsed body, or the 400 to answer. */
48
+ async function read(c, schema) {
49
+ const tenantId = tenantOf(c);
50
+ if (tenantId === false)
51
+ return { ok: false, response: c.json(invalid(`The ${TENANT_HEADER} header must name a Tenant`), 400) };
52
+ let raw;
53
+ try {
54
+ raw = await c.req.json();
55
+ }
56
+ catch {
57
+ return { ok: false, response: c.json(invalid("The body must be JSON"), 400) };
58
+ }
59
+ const parsed = schema.safeParse(raw);
60
+ if (!parsed.success)
61
+ return {
62
+ ok: false,
63
+ response: c.json(invalid(`Invalid request: ${parsed.error.issues[0]?.message ?? "malformed"}`), 400),
64
+ };
65
+ return { ok: true, tenantId, body: parsed.data };
66
+ }
67
+ if (options.keys)
68
+ mountKeysRoutes(app, {
69
+ authorized,
70
+ keys: options.keys,
71
+ logger: options.logger ?? { info() { }, warn() { }, error() { } },
72
+ });
73
+ const mcp = options.mcp;
74
+ if (mcp) {
75
+ app.post(MCP_CONNECT_PATH, authorized, toolBodies, async (c) => {
76
+ const request = await read(c, McpServerBodySchema);
77
+ if (!request.ok)
78
+ return request.response;
79
+ return c.json(await mcp.connect(request.tenantId, request.body.server));
80
+ });
81
+ app.post(MCP_LIST_PATH, authorized, toolBodies, async (c) => {
82
+ const request = await read(c, McpListBodySchema);
83
+ if (!request.ok)
84
+ return request.response;
85
+ const { server, cursor } = request.body;
86
+ return c.json(await mcp.list(request.tenantId, server, cursor, c.req.raw.signal));
87
+ });
88
+ app.post(MCP_CLOSE_PATH, authorized, toolBodies, async (c) => {
89
+ const request = await read(c, McpServerBodySchema);
90
+ if (!request.ok)
91
+ return request.response;
92
+ await mcp.close(request.body.server);
93
+ return c.body(null, 204);
94
+ });
95
+ const toolCalls = options.toolCalls;
96
+ app.post(`${TOOL_CALLS_PATH}/:key/cancel`, authorized, (c) => {
97
+ if (tenantOf(c) === false)
98
+ return c.json(invalid(`The ${TENANT_HEADER} header must name a Tenant`), 400);
99
+ toolCalls?.cancel(c.req.param("key"));
100
+ return c.body(null, 204);
101
+ });
102
+ app.post(TOOL_CALLS_PATH, authorized, toolBodies, async (c) => {
103
+ const request = await read(c, ToolCallBodySchema);
104
+ if (!request.ok)
105
+ return request.response;
106
+ const signal = c.req.raw.signal;
107
+ const key = c.req.header("idempotency-key");
108
+ try {
109
+ // Keyed: the call runs under its own signal, once, and outlives this request (G3).
110
+ const answer = key && toolCalls
111
+ ? await untilAborted(toolCalls.run(request.tenantId, key, createHash("sha256").update(canonical(request.body)).digest("hex"), request.body), signal)
112
+ : await mcp.call(request.tenantId, request.body, signal);
113
+ if (signal.aborted)
114
+ return new Response(null, { status: 499 });
115
+ return c.json(answer);
116
+ }
117
+ catch (error) {
118
+ if (error instanceof InflightConflict) {
119
+ const body = { error: { code: "gate_conflict", message: error.message } };
120
+ return c.json(body, 409);
121
+ }
122
+ if (signal.aborted)
123
+ return new Response(null, { status: 499 });
124
+ throw error;
125
+ }
126
+ });
127
+ }
128
+ const policy = options.delivery;
129
+ if (policy) {
130
+ app.post(DELIVERIES_PATH, authorized, toolBodies, async (c) => {
131
+ const request = await read(c, DeliveryBodySchema);
132
+ if (!request.ok)
133
+ return request.response;
134
+ const { url, body, headers, timeoutMs } = request.body;
135
+ const forwarded = {};
136
+ for (const [name, value] of Object.entries(headers)) {
137
+ if (!DELIVERY_HEADERS.has(name.toLowerCase()))
138
+ return c.json(invalid(`A delivery may not carry the header ${name}`), 400);
139
+ forwarded[name] = value;
140
+ }
141
+ const signal = c.req.raw.signal;
142
+ const result = await post(url, body, forwarded, {
143
+ policy,
144
+ signal: AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]),
145
+ });
146
+ if (signal.aborted)
147
+ return new Response(null, { status: 499 });
148
+ const answer = {
149
+ result: result.kind === "response"
150
+ ? {
151
+ kind: "response",
152
+ status: result.status,
153
+ headers: result.headers,
154
+ body: result.body.toString("base64"),
155
+ }
156
+ : result,
157
+ };
158
+ return c.json(answer);
159
+ });
160
+ }
161
+ app.post(`${MODEL_CALLS_PATH}/:key/cancel`, authorized, (c) => {
162
+ // Optional, as on a call: the gate serves one Tenant, so the key alone names the call.
163
+ const tenantId = c.req.header(TENANT_HEADER);
164
+ if (tenantId !== undefined && !isTenantId(tenantId))
165
+ return c.json(invalid(`The ${TENANT_HEADER} header must name a Tenant`), 400);
166
+ options.inflight.cancel(c.req.param("key"));
167
+ return c.body(null, 204);
168
+ });
169
+ app.post(MODEL_CALLS_PATH, authorized, bodyLimit({
170
+ maxSize,
171
+ onError: (c) => c.json(invalid(`A model call body may be at most ${maxSize} bytes`), 400),
172
+ }), async (c) => {
173
+ // Optional: the gate serves its database's one Tenant, and refuses a call naming another.
174
+ const tenantId = c.req.header(TENANT_HEADER);
175
+ if (tenantId !== undefined && !isTenantId(tenantId))
176
+ return c.json(invalid(`The ${TENANT_HEADER} header must name a Tenant`), 400);
177
+ let raw;
178
+ try {
179
+ raw = await c.req.json();
180
+ }
181
+ catch {
182
+ return c.json(invalid("The body must be JSON"), 400);
183
+ }
184
+ const parsed = ModelCallBodySchema.safeParse(raw);
185
+ if (!parsed.success)
186
+ return c.json(invalid(`Invalid model call: ${parsed.error.issues[0]?.message ?? "malformed"}`), 400);
187
+ const { call, ...ids } = parsed.data;
188
+ const request = {
189
+ ...(tenantId === undefined ? {} : { tenantId }),
190
+ ...ids,
191
+ call: call,
192
+ };
193
+ const signal = c.req.raw.signal;
194
+ const key = c.req.header("idempotency-key");
195
+ try {
196
+ let outcome;
197
+ if (key) {
198
+ // Keyed: the call runs under its own signal and outlives this request (P1.2). The
199
+ // gate serves one Tenant, so the effect id alone is the key.
200
+ const hash = createHash("sha256").update(canonical(parsed.data)).digest("hex");
201
+ outcome = await untilAborted(options.inflight.run(key, hash, (own) => options.modelGate.call(request, own)), signal);
202
+ }
203
+ else
204
+ outcome = await options.modelGate.call(request, signal);
205
+ return c.json({ outcome });
206
+ }
207
+ catch (error) {
208
+ if (error instanceof InflightConflict) {
209
+ const body = { error: { code: "gate_conflict", message: error.message } };
210
+ return c.json(body, 409);
211
+ }
212
+ // The caller went away (or cancelled): nobody reads the answer.
213
+ if (signal.aborted)
214
+ return new Response(null, { status: 499 });
215
+ throw error;
216
+ }
217
+ });
218
+ return app;
219
+ }
220
+ /** `promise`, or a rejection as soon as `signal` aborts (the call itself keeps running). */
221
+ function untilAborted(promise, signal) {
222
+ if (signal.aborted)
223
+ return Promise.reject(signal.reason);
224
+ return new Promise((resolve, reject) => {
225
+ const onAbort = () => reject(signal.reason);
226
+ signal.addEventListener("abort", onAbort, { once: true });
227
+ promise.then((value) => {
228
+ signal.removeEventListener("abort", onAbort);
229
+ resolve(value);
230
+ }, (error) => {
231
+ signal.removeEventListener("abort", onAbort);
232
+ reject(error);
233
+ });
234
+ });
235
+ }
@@ -70,14 +70,6 @@ export function tenantRoute(api, access, route, handler) {
70
70
  const schemes = [...new Set(access.credentials.map((credential) => SCHEMES[credential]))];
71
71
  const headers = z.object({
72
72
  "Nylorun-Protocol": z.string().meta({ description: `The protocol version, \`${PROTOCOL_VERSION}\`` }),
73
- ...(access.browser || takes("publishable")
74
- ? {
75
- "Nylorun-Tenant": z
76
- .string()
77
- .optional()
78
- .meta({ description: "The Tenant; optional with a publishable key, which names it" }),
79
- }
80
- : { "Nylorun-Tenant": z.string().meta({ description: "The Tenant" }) }),
81
73
  ...(takes("subject")
82
74
  ? {
83
75
  "Nylorun-Subject": z
@@ -35,15 +35,18 @@ export function accessRoutes(api) {
35
35
  tags: ["Access"],
36
36
  summary: "Get the public keys subject and delivery tokens are signed with",
37
37
  description: "A JSON Web Key Set, to verify a subject token or a delivery token without calling the " +
38
- "Runtime. No credential is needed: `Nylorun-Tenant` alone names the Tenant.",
38
+ "Runtime. No credential is needed.",
39
39
  responses: { 200: json(Jwks, "The public keys") },
40
40
  }, async (c) => {
41
- const keys = c.env.tenant.signingKeys;
42
- const kek = keys.kek();
43
- const rows = await c.env.tenant.store.tx(async (t) => {
44
- await keys.ensure(t, kek);
45
- return t.signingKeys(["standby", "current", "previous"]);
46
- });
41
+ // Anonymous: reads the public keys. Only when the current or standby key is missing does
42
+ // it ask the keys service to create it, so a verifier never caches a set without them.
43
+ const ctx = c.env.tenant;
44
+ const read = () => ctx.store.tx((t) => t.signingKeys(["standby", "current", "previous"]));
45
+ let rows = await read();
46
+ if (!complete(rows)) {
47
+ await ctx.keys.ensureSigningKeys();
48
+ rows = await read();
49
+ }
47
50
  return jsonResponse(200, { keys: rows.map(publicJwk) });
48
51
  });
49
52
  tenantRoute(api, APPLICATION, {
@@ -89,12 +92,13 @@ export function accessRoutes(api) {
89
92
  responses: { 200: json(SigningKeyList, "Standby, current, previous and revoked keys") },
90
93
  }, async (c) => {
91
94
  requireApplication(c.get("scope"));
92
- const keys = c.env.tenant.signingKeys;
93
- const kek = keys.kek();
94
- const rows = await c.env.tenant.store.tx(async (t) => {
95
- await keys.ensure(t, kek);
96
- return t.signingKeys();
97
- });
95
+ const ctx = c.env.tenant;
96
+ const read = () => ctx.store.tx((t) => t.signingKeys());
97
+ let rows = await read();
98
+ if (!complete(rows)) {
99
+ await ctx.keys.ensureSigningKeys();
100
+ rows = await read();
101
+ }
98
102
  return jsonResponse(200, { keys: rows.map(signingKeyView) });
99
103
  });
100
104
  tenantRoute(api, APPLICATION, {
@@ -112,11 +116,10 @@ export function accessRoutes(api) {
112
116
  requireApplication(c.get("scope"));
113
117
  const ctx = c.env.tenant;
114
118
  const request = RotateSigningKeysRequestSchema.parse(await readJson(c.req.raw));
115
- const keys = ctx.signingKeys;
116
- const kek = keys.kek();
117
- const rows = await ctx.store.tx(async (t) => keys.rotate(t, kek, (await readPolicy(t)).tokens.maxTtlSeconds, request.force === true));
119
+ const maxTtlSeconds = await ctx.store.tx(async (t) => (await readPolicy(t)).tokens.maxTtlSeconds);
120
+ const keys = await ctx.keys.rotateSigningKeys({ maxTtlSeconds, force: request.force === true });
118
121
  ctx.config.logger.info("signing keys rotated", { force: request.force === true });
119
- return jsonResponse(200, { keys: rows.map(signingKeyView) });
122
+ return jsonResponse(200, { keys });
120
123
  });
121
124
  tenantRoute(api, APPLICATION, {
122
125
  method: "post",
@@ -153,7 +156,7 @@ export function accessRoutes(api) {
153
156
  path: "/v1/access/publishable-keys",
154
157
  tags: ["Access"],
155
158
  summary: "Create a publishable key",
156
- description: "A key a browser page on one of the origins sends as `Nylorun-Key`. Public by design; it names the Tenant and grants the policy's anonymous scopes.",
159
+ description: "A key a browser page on one of the origins sends as `Nylorun-Key`. Public by design; it grants the policy's anonymous scopes.",
157
160
  request: { body: body(CreatePublishableKeyRequest) },
158
161
  responses: {
159
162
  200: json(PublishableKey, "The key"),
@@ -257,3 +260,7 @@ export async function revokeSubject(ctx, subject) {
257
260
  ctx.config.logger.info("subject revoked", { epoch });
258
261
  return { subject, epoch };
259
262
  }
263
+ /** True when the Tenant has a current and a standby signing key. */
264
+ function complete(rows) {
265
+ return rows.some((row) => row.state === "current") && rows.some((row) => row.state === "standby");
266
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * The Tenant's own settings (`/v1/tenant/**`): its status, reset, a first configuration, the
3
- * model it calls and the sandboxes its sessions get.
3
+ * model it calls, what its model calls cost and may cost (budgets), and the sandboxes its
4
+ * sessions get.
4
5
  *
5
6
  * A reset that deletes sessions moves the Tenant to a new basin generation, so a session
6
7
  * created again starts in an empty basin; the old basin is deleted after a grace period.
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
- import { PutHostModelRequestSchema, PutTenantSandboxRequestSchema, ResetTenantRequestSchema, SeedTenantConfigRequestSchema, SelectHostModelRequestSchema, } from "@nylorun/core/contracts";
3
- import { HostModelCatalog, HostModelView, ListProvidersResponse, PutHostModelRequest, PutTenantSandboxRequest, ResetTenantRequest, ResetTenantResponse, SeedTenantConfigRequest, SeedTenantConfigResponse, SelectHostModelRequest, TenantSandboxView, TenantStatus, } from "../../components.js";
2
+ import { ModelUsageQuerySchema, PutModelBudgetsRequestSchema, PutHostModelRequestSchema, PutTenantSandboxRequestSchema, ResetTenantRequestSchema, SeedTenantConfigRequestSchema, SelectHostModelRequestSchema, } from "@nylorun/core/contracts";
3
+ import { HostModelCatalog, HostModelView, ListProvidersResponse, ModelBudgets, ModelUsageTotals, PutModelBudgetsRequest, PutHostModelRequest, PutTenantSandboxRequest, ResetTenantRequest, ResetTenantResponse, SeedTenantConfigRequest, SeedTenantConfigResponse, SelectHostModelRequest, TenantSandboxView, TenantStatus, } from "../../components.js";
4
+ import { periodStart } from "../../../gates/meter.js";
4
5
  import { hostModelCatalog } from "../../../model/catalog.js";
5
6
  import { sandboxConfigErrors } from "../../../sandbox/resolve.js";
6
7
  import { effectiveSandboxConfig, readSandboxConfig, writeSandboxConfig, } from "../../../sandbox/tenant-config.js";
@@ -27,6 +28,21 @@ const body = (schema) => ({
27
28
  required: true,
28
29
  content: { "application/json": { schema } },
29
30
  });
31
+ const budgetRow = (budget, updatedAt) => ({
32
+ scope: budget.scope,
33
+ scopeId: budget.scope === "agent" ? budget.id : "*",
34
+ period: budget.period ?? null,
35
+ limitUsd: budget.limitUsd ?? null,
36
+ limitTokens: budget.limitTokens ?? null,
37
+ updatedAt,
38
+ });
39
+ const budgetView = (row) => ({
40
+ scope: row.scope,
41
+ ...(row.scope === "agent" ? { id: row.scopeId } : {}),
42
+ ...(row.period ? { period: row.period } : {}),
43
+ ...(row.limitUsd !== null ? { limitUsd: row.limitUsd } : {}),
44
+ ...(row.limitTokens !== null ? { limitTokens: row.limitTokens } : {}),
45
+ });
30
46
  /** `GET /v1/tenant/sandbox`: the backend report and the configuration with defaults applied. */
31
47
  async function sandboxView(ctx) {
32
48
  const config = await ctx.store.tx((t) => readSandboxConfig(t));
@@ -97,7 +113,7 @@ export function tenantRoutes(api) {
97
113
  }, async (c) => {
98
114
  const ctx = c.env.tenant;
99
115
  const request = SeedTenantConfigRequestSchema.parse(await readJson(c.req.raw));
100
- return jsonResponse(200, await seedTenantConfig({ store: ctx.store, vault: ctx.vault }, request));
116
+ return jsonResponse(200, await seedTenantConfig({ store: ctx.store, vault: ctx.vault, keys: ctx.keys }, request));
101
117
  });
102
118
  tenantRoute(api, settings(["tenant:settings", "agents:write"]), {
103
119
  method: "get",
@@ -133,6 +149,62 @@ export function tenantRoutes(api) {
133
149
  await ctx.store.tx((t) => writeSandboxConfig(t, config));
134
150
  return jsonResponse(200, await sandboxView(ctx));
135
151
  });
152
+ tenantRoute(api, SETTINGS, {
153
+ method: "get",
154
+ path: "/v1/tenant/usage",
155
+ tags: ["Tenant"],
156
+ summary: "Get what the Tenant's model calls used",
157
+ description: "Totals from the model usage ledger, which the model gate writes once per call: for the Tenant, one agent or one turn, over the current UTC day or month, or all time.",
158
+ request: { query: ModelUsageQuerySchema },
159
+ responses: { 200: json(ModelUsageTotals, "The calls, tokens and cost") },
160
+ }, async (c) => {
161
+ const parsed = ModelUsageQuerySchema.safeParse(c.req.query());
162
+ if (!parsed.success)
163
+ return fail(400, parsed.error.issues[0]?.message ?? "Invalid query");
164
+ const { scope, id, period } = parsed.data;
165
+ if (scope !== "tenant" && !id)
166
+ fail(400, `The ${scope} scope needs an id`);
167
+ const since = period === "total" ? undefined : periodStart(period, new Date());
168
+ const totals = await c.env.tenant.store.tx((t) => t.modelUsageTotals({
169
+ scope,
170
+ ...(scope !== "tenant" ? { id: id } : {}),
171
+ ...(since ? { since } : {}),
172
+ }));
173
+ return jsonResponse(200, {
174
+ scope,
175
+ ...(scope !== "tenant" ? { id } : {}),
176
+ period,
177
+ ...(since ? { since } : {}),
178
+ ...totals,
179
+ });
180
+ });
181
+ tenantRoute(api, SETTINGS, {
182
+ method: "get",
183
+ path: "/v1/tenant/budgets",
184
+ tags: ["Tenant"],
185
+ summary: "Get the Tenant's model budgets",
186
+ responses: { 200: json(ModelBudgets, "The hard caps on model spend") },
187
+ }, async (c) => {
188
+ const rows = await c.env.tenant.store.tx((t) => t.listModelBudgets());
189
+ return jsonResponse(200, { budgets: rows.map(budgetView) });
190
+ });
191
+ tenantRoute(api, SETTINGS, {
192
+ method: "put",
193
+ path: "/v1/tenant/budgets",
194
+ tags: ["Tenant"],
195
+ summary: "Set the Tenant's model budgets",
196
+ description: "Replaces every budget. Before each model call the gate checks the scope's spend against its cap; once a cap is reached the call fails with `budget_exhausted` and the turn with `model.budget_exhausted`. An empty list removes every cap.",
197
+ request: { body: body(PutModelBudgetsRequest) },
198
+ responses: { 200: json(ModelBudgets, "The budgets now in force") },
199
+ }, async (c) => {
200
+ const { budgets } = PutModelBudgetsRequestSchema.parse(await readJson(c.req.raw));
201
+ const updatedAt = new Date().toISOString();
202
+ const rows = await c.env.tenant.store.tx(async (t) => {
203
+ await t.putModelBudgets(budgets.map((budget) => budgetRow(budget, updatedAt)));
204
+ return t.listModelBudgets();
205
+ });
206
+ return jsonResponse(200, { budgets: rows.map(budgetView) });
207
+ });
136
208
  tenantRoute(api, settings(["tenant:settings", "agents:write"]), {
137
209
  method: "get",
138
210
  path: "/v1/tenant/providers",
@@ -156,7 +228,7 @@ export function tenantRoutes(api) {
156
228
  responses: { 200: json(HostModelView, "The model now in force") },
157
229
  }, async (c) => {
158
230
  const ctx = c.env.tenant;
159
- return jsonResponse(200, await ctx.vault.putHostModel(PutHostModelRequestSchema.parse(await readJson(c.req.raw))));
231
+ return jsonResponse(200, await ctx.keys.putHostModel(PutHostModelRequestSchema.parse(await readJson(c.req.raw))));
160
232
  });
161
233
  tenantRoute(api, SETTINGS, {
162
234
  method: "put",
@@ -167,6 +239,6 @@ export function tenantRoutes(api) {
167
239
  responses: { 200: json(HostModelView, "The model now in force") },
168
240
  }, async (c) => {
169
241
  const ctx = c.env.tenant;
170
- return jsonResponse(200, await ctx.vault.selectHostModel(SelectHostModelRequestSchema.parse(await readJson(c.req.raw))));
242
+ return jsonResponse(200, await ctx.keys.selectHostModel(SelectHostModelRequestSchema.parse(await readJson(c.req.raw))));
171
243
  });
172
244
  }
@@ -112,7 +112,7 @@ export function vaultRoutes(api) {
112
112
  fail(403, "A subject token cannot store OAuth refresh credentials", {
113
113
  code: "scope_required",
114
114
  });
115
- return jsonResponse(200, await c.env.tenant.vault.createCredential(c.req.param("vaultId"), request));
115
+ return jsonResponse(200, await c.env.tenant.keys.createCredential(c.req.param("vaultId"), request));
116
116
  });
117
117
  tenantRoute(api, OWN_VAULTS, {
118
118
  method: "get",
@@ -151,7 +151,7 @@ export function vaultRoutes(api) {
151
151
  }, async (c) => {
152
152
  await ownerFor(c);
153
153
  const request = RotateCredentialRequestSchema.parse(await readJson(c.req.raw));
154
- return jsonResponse(200, await c.env.tenant.vault.rotateCredential(c.req.param("vaultId"), c.req.param("credentialId"), request));
154
+ return jsonResponse(200, await c.env.tenant.keys.rotateCredential(c.req.param("vaultId"), c.req.param("credentialId"), request));
155
155
  });
156
156
  tenantRoute(api, OWN_VAULTS, {
157
157
  method: "delete",
@@ -49,7 +49,7 @@ function hostRoutes() {
49
49
  path: "/ready",
50
50
  tags: ["Host"],
51
51
  summary: "Check the Runtime is ready",
52
- description: "The listeners, Tenant discovery, Postgres, Restate and S2.",
52
+ description: "The listeners, the Tenant (open), Postgres, Restate and S2.",
53
53
  responses: {
54
54
  200: {
55
55
  description: "Ready",
@@ -75,7 +75,7 @@ function tenantSchemes(registry) {
75
75
  type: "apiKey",
76
76
  in: "header",
77
77
  name: "Nylorun-Key",
78
- description: "A publishable key, sent by a browser page on one of its origins. Public by design: it names the Tenant and grants the access policy's anonymous scopes.",
78
+ description: "A publishable key, sent by a browser page on one of its origins. Public by design: it grants the access policy's anonymous scopes.",
79
79
  });
80
80
  }
81
81
  let tenant;
@@ -94,13 +94,13 @@ export function tenantDocument() {
94
94
  info: {
95
95
  title: "Nylorun Runtime: Tenant API",
96
96
  version: RUNTIME_VERSION,
97
- description: "Agents, sessions and their events, vaults, access and the Tenant's settings, for one Tenant (`Nylorun-Tenant`). Every request sends `Nylorun-Protocol`. Who may call each operation is its `security` and its `x-nylorun-credentials`, `x-nylorun-scopes` (the subject scopes that reach it) and `x-nylorun-browser` fields.",
97
+ description: "Agents, sessions and their events, vaults, access and the Tenant's settings. A Runtime serves one Tenant: its URL is the Tenant's, and no request names it. Every request sends `Nylorun-Protocol`. Who may call each operation is its `security` and its `x-nylorun-credentials`, `x-nylorun-scopes` (the subject scopes that reach it) and `x-nylorun-browser` fields.",
98
98
  "x-nylorun-protocol": PROTOCOL,
99
99
  },
100
100
  servers: [
101
101
  {
102
102
  url: "{origin}",
103
- description: "A Runtime: the local stack (`nylorun start`), or where yours runs",
103
+ description: "A Runtime: a local Tenant (`nylorun start`), or where yours runs",
104
104
  variables: { origin: { default: "http://localhost:8787" } },
105
105
  },
106
106
  ],
@@ -113,7 +113,6 @@ export function adminDocument() {
113
113
  return admin;
114
114
  // Only the routes' declarations are read: nothing is served from this app.
115
115
  const api = createAdminApi({
116
- module: undefined,
117
116
  status: () => Promise.reject(new Error("Not served")),
118
117
  shutdown: () => { },
119
118
  });
@@ -123,13 +122,13 @@ export function adminDocument() {
123
122
  info: {
124
123
  title: "Nylorun Runtime: Admin API",
125
124
  version: RUNTIME_VERSION,
126
- description: "Tenants and the Host's status, with the admin key. Served on the operator listener when the Host has one; the public listener answers these routes with the opaque 404.",
125
+ description: "The Host's status, its Tenant's state and shutdown, with the admin key. Served on the operator listener when the Host has one; the public listener answers these routes with the opaque 404.",
127
126
  "x-nylorun-protocol": PROTOCOL,
128
127
  },
129
128
  servers: [
130
129
  {
131
130
  url: "{origin}",
132
- description: "The operator listener: the local stack's, or where yours runs",
131
+ description: "The operator listener: a local Tenant's, or where yours runs",
133
132
  variables: { origin: { default: "http://localhost:8788" } },
134
133
  },
135
134
  ],
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The gates service's wire format (blueprint §15, P1.1). Internal: only the loop calls it, so
3
+ * it is not in the published OpenAPI documents.
4
+ *
5
+ * `POST /nylorun/v1/model-calls`
6
+ * - Headers: `Authorization: Bearer <NYLORUN_GATES_TOKEN>`, `Idempotency-Key: <effect id>`,
7
+ * and optionally `Nylorun-Tenant: <tenant id>`, which must then be the database's one
8
+ * Tenant (a call naming another is refused).
9
+ * - Body: `ModelCallBody`, at most `MAX_MODEL_CALL_BYTES`.
10
+ * - `200 {outcome}` once the call has finished, whether the outcome is a candidate or a
11
+ * failure; nothing is sent while it runs. `401 gate_unauthorized` for a missing or wrong
12
+ * token, `400 invalid_request` for a malformed Tenant header or a bad body, `409
13
+ * gate_conflict` when a different request already runs under the same key.
14
+ * - With an `Idempotency-Key` the call outlives its client (P1.2): a re-send with the same key
15
+ * and body joins it, or gets its outcome for 30 minutes. Without one, a client that goes
16
+ * away aborts the call.
17
+ *
18
+ * `POST /nylorun/v1/model-calls/{key}/cancel` (same headers): aborts the keyed call; `204`
19
+ * whether or not it exists.
20
+ */
21
+ import { z } from "zod";
22
+ import type { ModelGateOutcome } from "./model-gate.js";
23
+ export declare const MODEL_CALLS_PATH = "/nylorun/v1/model-calls";
24
+ /** Header naming the Tenant a call is for: optional, and checked against the gate's Tenant. */
25
+ export declare const TENANT_HEADER = "nylorun-tenant";
26
+ /** Largest request body the gate reads: one `ModelCall` with its whole projected history. */
27
+ export declare const MAX_MODEL_CALL_BYTES: number;
28
+ export declare const ModelCallBodySchema: z.ZodObject<{
29
+ sessionId: z.ZodString;
30
+ turnId: z.ZodString;
31
+ agentId: z.ZodString;
32
+ effectId: z.ZodString;
33
+ invocationId: z.ZodString;
34
+ call: z.ZodObject<{
35
+ prompt: z.ZodArray<z.ZodUnknown>;
36
+ tools: z.ZodArray<z.ZodObject<{
37
+ name: z.ZodString;
38
+ }, z.core.$loose>>;
39
+ }, z.core.$loose>;
40
+ }, z.core.$strip>;
41
+ export type ModelCallBody = z.infer<typeof ModelCallBodySchema>;
42
+ export interface ModelCallResponse {
43
+ readonly outcome: ModelGateOutcome;
44
+ }
45
+ /** An error answer: `{error: {code, message}}`. */
46
+ export interface GateErrorBody {
47
+ readonly error: {
48
+ readonly code: "gate_unauthorized" | "invalid_request" | "gate_conflict";
49
+ readonly message: string;
50
+ };
51
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The gates service's wire format (blueprint §15, P1.1). Internal: only the loop calls it, so
3
+ * it is not in the published OpenAPI documents.
4
+ *
5
+ * `POST /nylorun/v1/model-calls`
6
+ * - Headers: `Authorization: Bearer <NYLORUN_GATES_TOKEN>`, `Idempotency-Key: <effect id>`,
7
+ * and optionally `Nylorun-Tenant: <tenant id>`, which must then be the database's one
8
+ * Tenant (a call naming another is refused).
9
+ * - Body: `ModelCallBody`, at most `MAX_MODEL_CALL_BYTES`.
10
+ * - `200 {outcome}` once the call has finished, whether the outcome is a candidate or a
11
+ * failure; nothing is sent while it runs. `401 gate_unauthorized` for a missing or wrong
12
+ * token, `400 invalid_request` for a malformed Tenant header or a bad body, `409
13
+ * gate_conflict` when a different request already runs under the same key.
14
+ * - With an `Idempotency-Key` the call outlives its client (P1.2): a re-send with the same key
15
+ * and body joins it, or gets its outcome for 30 minutes. Without one, a client that goes
16
+ * away aborts the call.
17
+ *
18
+ * `POST /nylorun/v1/model-calls/{key}/cancel` (same headers): aborts the keyed call; `204`
19
+ * whether or not it exists.
20
+ */
21
+ import { z } from "zod";
22
+ export const MODEL_CALLS_PATH = "/nylorun/v1/model-calls";
23
+ /** Header naming the Tenant a call is for: optional, and checked against the gate's Tenant. */
24
+ export const TENANT_HEADER = "nylorun-tenant";
25
+ /** Largest request body the gate reads: one `ModelCall` with its whole projected history. */
26
+ export const MAX_MODEL_CALL_BYTES = 32 * 1024 * 1024;
27
+ export const ModelCallBodySchema = z.object({
28
+ sessionId: z.string().min(1),
29
+ turnId: z.string().min(1),
30
+ agentId: z.string().min(1),
31
+ effectId: z.string().min(1),
32
+ invocationId: z.string().min(1),
33
+ call: z.looseObject({
34
+ prompt: z.array(z.unknown()),
35
+ tools: z.array(z.looseObject({ name: z.string() })),
36
+ }),
37
+ });
@@ -0,0 +1,21 @@
1
+ import type { Logger } from "../tenant/types.js";
2
+ import type { ModelCallSettings, ModelGate, ModelGateRequest } from "./model-gate.js";
3
+ import { type TenantVaults } from "./tenant-vaults.js";
4
+ export interface ModelCallHandlerOptions {
5
+ readonly vaults: TenantVaults;
6
+ readonly logger: Logger;
7
+ /** Retries and timeouts; `piModel`'s defaults when absent. */
8
+ readonly settings?: ModelCallSettings;
9
+ }
10
+ /**
11
+ * A model call as the gate receives it: the Tenant is optional (the loop of a protocol 4
12
+ * Runtime names it; the gate serves the database's one Tenant either way).
13
+ */
14
+ export type ModelCallRequest = Omit<ModelGateRequest, "tenantId"> & {
15
+ readonly tenantId?: string;
16
+ };
17
+ /** What the gate's route calls: a `ModelGate` whose request may leave the Tenant out. */
18
+ export interface ModelCallHandler {
19
+ call(request: ModelCallRequest, signal: AbortSignal): ReturnType<ModelGate["call"]>;
20
+ }
21
+ export declare function createModelCallHandler(options: ModelCallHandlerOptions): ModelCallHandler;