@nylorun/runtime 0.16.0-beta → 0.17.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 (120) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +39 -27
  3. package/dist/admin-openapi.json +307 -7
  4. package/dist/api/ag-ui/endpoint.js +1 -2
  5. package/dist/api/ag-ui/routes.d.ts +1 -1
  6. package/dist/api/ag-ui/routes.js +10 -12
  7. package/dist/api/components.d.ts +54 -116
  8. package/dist/api/components.js +8 -11
  9. package/dist/api/http/app.js +2 -0
  10. package/dist/api/http/define.d.ts +9 -12
  11. package/dist/api/http/define.js +11 -26
  12. package/dist/api/http/routes/access.d.ts +3 -19
  13. package/dist/api/http/routes/access.js +12 -172
  14. package/dist/api/http/routes/actions.js +0 -1
  15. package/dist/api/http/routes/artifacts.js +1 -3
  16. package/dist/api/http/routes/me.d.ts +13 -0
  17. package/dist/api/http/routes/me.js +45 -0
  18. package/dist/api/http/routes/sandboxes.d.ts +2 -2
  19. package/dist/api/http/routes/sandboxes.js +1 -3
  20. package/dist/api/http/routes/sessions.js +6 -12
  21. package/dist/api/http/routes/vaults.d.ts +12 -3
  22. package/dist/api/http/routes/vaults.js +122 -54
  23. package/dist/api/openapi.js +4 -10
  24. package/dist/artifacts/links.js +6 -6
  25. package/dist/artifacts/service.js +1 -1
  26. package/dist/gates/mcp-handler.js +5 -1
  27. package/dist/gates/tenant-vaults.d.ts +18 -9
  28. package/dist/gates/tenant-vaults.js +17 -12
  29. package/dist/host/admin-api.d.ts +12 -5
  30. package/dist/host/admin-api.js +72 -6
  31. package/dist/host/app.d.ts +0 -1
  32. package/dist/host/app.js +13 -23
  33. package/dist/host/config-for.d.ts +4 -0
  34. package/dist/host/config-for.js +2 -0
  35. package/dist/host/config.d.ts +0 -6
  36. package/dist/host/create-host.d.ts +3 -10
  37. package/dist/host/create-host.js +4 -1
  38. package/dist/host/gates.js +6 -1
  39. package/dist/host/main.js +32 -10
  40. package/dist/host/stack-config.d.ts +11 -8
  41. package/dist/host/stack-config.js +23 -12
  42. package/dist/index.d.ts +3 -0
  43. package/dist/index.js +2 -0
  44. package/dist/keys/client.js +9 -1
  45. package/dist/keys/keys.d.ts +12 -2
  46. package/dist/keys/keys.js +8 -0
  47. package/dist/mcp/connect.js +2 -1
  48. package/dist/mcp/pool.d.ts +1 -0
  49. package/dist/mcp/pool.js +1 -0
  50. package/dist/openapi.json +2359 -4096
  51. package/dist/store/postgres/drizzle/0008_installation_vaults.sql +2 -0
  52. package/dist/store/postgres/drizzle/0009_oauth_pending.sql +19 -0
  53. package/dist/store/postgres/drizzle/0010_oss_auth_removals.sql +3 -0
  54. package/dist/store/postgres/drizzle/meta/_journal.json +21 -0
  55. package/dist/store/postgres/schema.d.ts +194 -170
  56. package/dist/store/postgres/schema.js +46 -28
  57. package/dist/store/postgres/store.js +51 -100
  58. package/dist/store/postgres/tenant.d.ts +1 -1
  59. package/dist/store/postgres/tenant.js +2 -2
  60. package/dist/store/types.d.ts +24 -30
  61. package/dist/streams/control.d.ts +0 -5
  62. package/dist/streams/control.js +0 -8
  63. package/dist/streams/types.d.ts +2 -11
  64. package/dist/streams/types.js +1 -1
  65. package/dist/tenant/auth.d.ts +13 -9
  66. package/dist/tenant/auth.js +25 -32
  67. package/dist/tenant/commands.js +3 -7
  68. package/dist/tenant/context.d.ts +20 -22
  69. package/dist/tenant/delivery-token.js +4 -4
  70. package/dist/tenant/effects.d.ts +5 -1
  71. package/dist/tenant/effects.js +5 -16
  72. package/dist/tenant/ephemeral.d.ts +14 -2
  73. package/dist/tenant/ephemeral.js +8 -1
  74. package/dist/tenant/host-token.js +5 -5
  75. package/dist/tenant/identity-file.d.ts +75 -0
  76. package/dist/tenant/identity-file.js +202 -0
  77. package/dist/tenant/issuers.d.ts +48 -0
  78. package/dist/tenant/issuers.js +311 -0
  79. package/dist/tenant/jwt.js +3 -3
  80. package/dist/tenant/operator-keys.d.ts +18 -0
  81. package/dist/tenant/operator-keys.js +55 -0
  82. package/dist/tenant/outbound.d.ts +30 -0
  83. package/dist/tenant/outbound.js +142 -10
  84. package/dist/tenant/principals.d.ts +2 -11
  85. package/dist/tenant/principals.js +10 -30
  86. package/dist/tenant/run-token.js +3 -3
  87. package/dist/tenant/runtime.d.ts +4 -0
  88. package/dist/tenant/runtime.js +14 -1
  89. package/dist/tenant/sandboxes.d.ts +5 -5
  90. package/dist/tenant/sandboxes.js +5 -5
  91. package/dist/tenant/session-streams.d.ts +6 -10
  92. package/dist/tenant/session-streams.js +9 -27
  93. package/dist/tenant/sessions.d.ts +2 -2
  94. package/dist/tenant/sessions.js +1 -1
  95. package/dist/tenant/signing-keys.d.ts +2 -1
  96. package/dist/tenant/signing-keys.js +2 -2
  97. package/dist/tenant/streams.d.ts +2 -3
  98. package/dist/tenant/streams.js +1 -5
  99. package/dist/tenant/types.d.ts +21 -0
  100. package/dist/vault/crypto.d.ts +7 -0
  101. package/dist/vault/crypto.js +16 -0
  102. package/dist/vault/oauth.d.ts +55 -0
  103. package/dist/vault/oauth.js +186 -0
  104. package/dist/vault/service.d.ts +54 -7
  105. package/dist/vault/service.js +261 -17
  106. package/dist/vault/sources.d.ts +54 -0
  107. package/dist/vault/sources.js +185 -0
  108. package/dist/version.d.ts +1 -1
  109. package/dist/version.js +1 -1
  110. package/package.json +4 -3
  111. package/dist/host/cors.d.ts +0 -27
  112. package/dist/host/cors.js +0 -64
  113. package/dist/tenant/access-policy.d.ts +0 -30
  114. package/dist/tenant/access-policy.js +0 -52
  115. package/dist/tenant/browser.d.ts +0 -15
  116. package/dist/tenant/browser.js +0 -39
  117. package/dist/tenant/subject-limits.d.ts +0 -16
  118. package/dist/tenant/subject-limits.js +0 -45
  119. package/dist/tenant/tokens.d.ts +0 -14
  120. package/dist/tenant/tokens.js +0 -187
@@ -1,16 +1,25 @@
1
1
  import { z } from "zod";
2
- import { CreateCredentialRequestSchema, CreateVaultRequestSchema, RotateCredentialRequestSchema, } from "@nylorun/core/contracts";
3
- import { CreateCredentialRequest, CreateVaultRequest, CredentialInfo, DeletedResponse, ListCredentialsResponse, ListVaultsResponse, RotateCredentialRequest, VaultInfo, } from "../../components.js";
4
- import { ownerOf } from "../../../tenant/auth.js";
5
- import { fail } from "../../../tenant/http.js";
2
+ import { CreateCredentialRequestSchema, CreateVaultRequestSchema, RotateCredentialRequestSchema, StartOAuthRequestSchema, } from "@nylorun/core/contracts";
3
+ import { CreateCredentialRequest, CreateVaultRequest, CredentialInfo, DeletedResponse, ListCredentialsResponse, ListVaultsResponse, RotateCredentialRequest, StartOAuthRequest, StartOAuthResponse, VaultInfo, } from "../../components.js";
4
+ import { HttpError } from "../../../tenant/http.js";
5
+ import { VaultError } from "../../../vault/error.js";
6
6
  import { readJson } from "../body.js";
7
- import { pathSegments, tenantRoute } from "../define.js";
7
+ import { tenantRoute } from "../define.js";
8
8
  import { jsonResponse } from "../respond.js";
9
- const OWN_VAULTS = {
9
+ /** Every vault route, and starting an MCP OAuth connect: an application key acting for no one. */
10
+ const APPLICATION = { credentials: ["application"], scopes: "never" };
11
+ /**
12
+ * The OAuth callback: a browser sent back by the authorization server, with no credential and no
13
+ * protocol header. The `state` it carries is the grant.
14
+ */
15
+ const CALLBACK = {
10
16
  credentials: ["application", "subject", "token"],
11
- scopes: ["vaults:own"],
12
- browser: true,
17
+ scopes: "any",
18
+ anonymous: true,
19
+ unversioned: true,
13
20
  };
21
+ /** Where the authorization server sends the browser back. */
22
+ export const OAUTH_CALLBACK_PATH = "/v1/oauth/callback";
14
23
  const json = (schema, description) => ({
15
24
  description,
16
25
  content: { "application/json": { schema } },
@@ -21,58 +30,45 @@ const body = (schema) => ({
21
30
  });
22
31
  const vaultId = z.object({ vaultId: z.string() });
23
32
  const credentialId = vaultId.extend({ credentialId: z.string() });
24
- /**
25
- * Who a vault route acts for: the subject, or no one in particular (an application key). Below
26
- * a vault, the vault must be the subject's before anything else.
27
- */
28
- async function ownerFor(c) {
29
- const { vault } = c.env.tenant;
30
- const owner = ownerOf(c.get("scope"));
31
- const id = c.req.param("vaultId");
32
- if (id !== undefined && owner !== undefined)
33
- await vault.assertOwner(id, owner);
34
- return owner;
35
- }
36
33
  export function vaultRoutes(api) {
37
- tenantRoute(api, OWN_VAULTS, {
34
+ tenantRoute(api, APPLICATION, {
38
35
  method: "post",
39
36
  path: "/v1/vaults",
40
37
  tags: ["Vaults"],
41
38
  summary: "Create a vault",
39
+ description: "With `scope: \"installation\"` the installation's own vault, owned by `installation`, which any session may attach; or a vault of one person (`ownerUserId`), which only that person's sessions attach.",
42
40
  request: { body: body(CreateVaultRequest) },
43
41
  responses: {
44
42
  200: json(VaultInfo, "The vault"),
45
43
  409: { description: "The idempotency key was used for another vault" },
46
44
  },
47
45
  }, async (c) => {
48
- const owner = await ownerFor(c);
49
46
  const request = CreateVaultRequestSchema.parse(await readJson(c.req.raw));
50
- if (owner !== undefined && request.ownerUserId !== owner)
51
- fail(403, "ownerUserId must be the subject");
52
47
  return jsonResponse(200, await c.env.tenant.vault.createVault(request));
53
48
  });
54
- tenantRoute(api, OWN_VAULTS, {
49
+ tenantRoute(api, APPLICATION, {
55
50
  method: "get",
56
51
  path: "/v1/vaults",
57
52
  tags: ["Vaults"],
58
- summary: "List a person's vaults",
53
+ summary: "List vaults",
54
+ description: "The installation vaults, after the vaults of `ownerUserId` when it names a person.",
59
55
  request: {
60
56
  query: z.object({
61
57
  ownerUserId: z
62
58
  .string()
63
59
  .optional()
64
- .meta({ description: "Whose vaults; required with an application key acting for no one" }),
60
+ .meta({ description: "Also list this person's vaults" }),
65
61
  }),
66
62
  },
67
63
  responses: { 200: json(ListVaultsResponse, "The vaults") },
68
64
  }, async (c) => {
69
- const owner = await ownerFor(c);
70
- const ownerUserId = c.req.query("ownerUserId") ?? owner ?? fail(400, "ownerUserId is required");
71
- if (owner !== undefined && ownerUserId !== owner)
72
- fail(403, "ownerUserId must be the subject");
73
- return jsonResponse(200, { vaults: await c.env.tenant.vault.listVaults(ownerUserId) });
65
+ return jsonResponse(200, {
66
+ vaults: await c.env.tenant.vault.listVaults(c.req.query("ownerUserId"), {
67
+ installation: true,
68
+ }),
69
+ });
74
70
  });
75
- tenantRoute(api, OWN_VAULTS, {
71
+ tenantRoute(api, APPLICATION, {
76
72
  method: "get",
77
73
  path: "/v1/vaults/{vaultId}",
78
74
  tags: ["Vaults"],
@@ -80,10 +76,9 @@ export function vaultRoutes(api) {
80
76
  request: { params: vaultId },
81
77
  responses: { 200: json(VaultInfo, "The vault") },
82
78
  }, async (c) => {
83
- await ownerFor(c);
84
79
  return jsonResponse(200, await c.env.tenant.vault.getVault(c.req.param("vaultId")));
85
80
  });
86
- tenantRoute(api, OWN_VAULTS, {
81
+ tenantRoute(api, APPLICATION, {
87
82
  method: "delete",
88
83
  path: "/v1/vaults/{vaultId}",
89
84
  tags: ["Vaults"],
@@ -91,30 +86,21 @@ export function vaultRoutes(api) {
91
86
  request: { params: vaultId },
92
87
  responses: { 200: json(DeletedResponse, "Deleted") },
93
88
  }, async (c) => {
94
- await ownerFor(c);
95
89
  return jsonResponse(200, await c.env.tenant.vault.deleteVault(c.req.param("vaultId")));
96
90
  });
97
- tenantRoute(api, OWN_VAULTS, {
91
+ tenantRoute(api, APPLICATION, {
98
92
  method: "post",
99
93
  path: "/v1/vaults/{vaultId}/credentials",
100
94
  tags: ["Vaults"],
101
95
  summary: "Add a credential",
102
- description: "A bearer token or OAuth tokens, bound to the URLs a tool may send them to. A subject token cannot store OAuth refresh credentials.",
96
+ description: "A bearer token or OAuth tokens, bound to the URLs a tool may send them to.",
103
97
  request: { params: vaultId, body: body(CreateCredentialRequest) },
104
98
  responses: { 200: json(CredentialInfo, "The credential, without its secret") },
105
99
  }, async (c) => {
106
- await ownerFor(c);
107
100
  const request = CreateCredentialRequestSchema.parse(await readJson(c.req.raw));
108
- // The Runtime calls a refresh credential's token endpoint itself: never for a browser.
109
- if (c.get("scope").kind === "token" &&
110
- request.auth.type === "oauth" &&
111
- request.auth.refresh)
112
- fail(403, "A subject token cannot store OAuth refresh credentials", {
113
- code: "scope_required",
114
- });
115
101
  return jsonResponse(200, await c.env.tenant.keys.createCredential(c.req.param("vaultId"), request));
116
102
  });
117
- tenantRoute(api, OWN_VAULTS, {
103
+ tenantRoute(api, APPLICATION, {
118
104
  method: "get",
119
105
  path: "/v1/vaults/{vaultId}/credentials",
120
106
  tags: ["Vaults"],
@@ -122,12 +108,11 @@ export function vaultRoutes(api) {
122
108
  request: { params: vaultId },
123
109
  responses: { 200: json(ListCredentialsResponse, "The credentials, without their secrets") },
124
110
  }, async (c) => {
125
- await ownerFor(c);
126
111
  return jsonResponse(200, {
127
112
  credentials: await c.env.tenant.vault.listCredentials(c.req.param("vaultId")),
128
113
  });
129
114
  });
130
- tenantRoute(api, OWN_VAULTS, {
115
+ tenantRoute(api, APPLICATION, {
131
116
  method: "get",
132
117
  path: "/v1/vaults/{vaultId}/credentials/{credentialId}",
133
118
  tags: ["Vaults"],
@@ -135,10 +120,9 @@ export function vaultRoutes(api) {
135
120
  request: { params: credentialId },
136
121
  responses: { 200: json(CredentialInfo, "The credential, without its secret") },
137
122
  }, async (c) => {
138
- await ownerFor(c);
139
123
  return jsonResponse(200, await c.env.tenant.vault.getCredential(c.req.param("vaultId"), c.req.param("credentialId")));
140
124
  });
141
- tenantRoute(api, OWN_VAULTS, {
125
+ tenantRoute(api, APPLICATION, {
142
126
  method: "post",
143
127
  path: "/v1/vaults/{vaultId}/credentials/{credentialId}",
144
128
  tags: ["Vaults"],
@@ -149,11 +133,10 @@ export function vaultRoutes(api) {
149
133
  409: { description: "A credential's type cannot change" },
150
134
  },
151
135
  }, async (c) => {
152
- await ownerFor(c);
153
136
  const request = RotateCredentialRequestSchema.parse(await readJson(c.req.raw));
154
137
  return jsonResponse(200, await c.env.tenant.keys.rotateCredential(c.req.param("vaultId"), c.req.param("credentialId"), request));
155
138
  });
156
- tenantRoute(api, OWN_VAULTS, {
139
+ tenantRoute(api, APPLICATION, {
157
140
  method: "delete",
158
141
  path: "/v1/vaults/{vaultId}/credentials/{credentialId}",
159
142
  tags: ["Vaults"],
@@ -161,7 +144,92 @@ export function vaultRoutes(api) {
161
144
  request: { params: credentialId },
162
145
  responses: { 200: json(DeletedResponse, "Deleted") },
163
146
  }, async (c) => {
164
- await ownerFor(c);
165
147
  return jsonResponse(200, await c.env.tenant.vault.deleteCredential(c.req.param("vaultId"), c.req.param("credentialId")));
166
148
  });
149
+ tenantRoute(api, APPLICATION, {
150
+ method: "post",
151
+ path: "/v1/vaults/{vaultId}/oauth/start",
152
+ tags: ["Vaults"],
153
+ summary: "Start an MCP OAuth connect",
154
+ description: "Signs the installation in to the remote MCP server at `url` (declared as `server`) and stores its OAuth credential, bound to `url`, in this installation vault. The Runtime discovers the server's authorization server (RFC 9728, RFC 8414), registers itself (RFC 7591) unless `clientId` names a registered client, and answers the URL to open in a browser; the sign-in returns to `GET /v1/oauth/callback` within `expiresAt`. The callback URL is `NYLORUN_PUBLIC_URL` + `/v1/oauth/callback`, or this request's own origin when the Host has no public URL. Application keys acting for no one only. `oauth_client_required` when the server offers no registration and no `clientId` was given.",
155
+ request: { params: vaultId, body: body(StartOAuthRequest) },
156
+ responses: {
157
+ 200: json(StartOAuthResponse, "Where to send the browser"),
158
+ 502: {
159
+ description: "The authorization server, or its discovery, failed or refused (`oauth_failed`)",
160
+ },
161
+ },
162
+ }, async (c) => {
163
+ const request = StartOAuthRequestSchema.parse(await readJson(c.req.raw));
164
+ const tenant = c.env.tenant;
165
+ const base = tenant.config.publicUrl ?? new URL(c.req.url).origin;
166
+ return jsonResponse(200, await tenant.keys.startOAuth({
167
+ vaultId: c.req.param("vaultId"),
168
+ server: request.server,
169
+ url: request.url,
170
+ ...(request.clientId === undefined ? {} : { clientId: request.clientId }),
171
+ redirectUri: `${base}${OAUTH_CALLBACK_PATH}`,
172
+ }));
173
+ });
174
+ tenantRoute(api, CALLBACK, {
175
+ method: "get",
176
+ path: OAUTH_CALLBACK_PATH,
177
+ tags: ["Vaults"],
178
+ summary: "Finish an MCP OAuth connect",
179
+ description: "Where the authorization server sends the browser back with `code` and `state` (or `error`). Exchanges the code once and stores the credential; answers a small HTML page. Needs no credential and no `Nylorun-Protocol`. A `state` is used once, for ten minutes (`oauth_state_invalid`).",
180
+ request: {
181
+ query: z.object({
182
+ code: z.string().optional(),
183
+ state: z.string().optional(),
184
+ error: z.string().optional(),
185
+ }),
186
+ },
187
+ responses: {
188
+ 200: { description: "Connected", content: { "text/html": { schema: z.string() } } },
189
+ 502: { description: "The authorization server refused the code (`oauth_failed`)" },
190
+ },
191
+ }, async (c) => {
192
+ const state = c.req.query("state");
193
+ const code = c.req.query("code");
194
+ const error = c.req.query("error");
195
+ if (!state)
196
+ return page(400, "Sign-in failed", "The authorization server sent no state. Start the connect again.");
197
+ try {
198
+ await c.env.tenant.keys.finishOAuth({
199
+ state,
200
+ ...(code === undefined ? {} : { code }),
201
+ ...(error === undefined ? {} : { error }),
202
+ });
203
+ }
204
+ catch (failure) {
205
+ if (failure instanceof HttpError || failure instanceof VaultError)
206
+ return page(failure.status, "Sign-in failed", failure.message);
207
+ c.env.tenant.config.logger.error("oauth_callback_failed", {
208
+ error: failure instanceof Error ? failure.name : "unknown",
209
+ });
210
+ return page(500, "Sign-in failed", "The connect failed. Start it again.");
211
+ }
212
+ return page(200, "Connected", "Connected. You can close this tab.");
213
+ });
214
+ }
215
+ const ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
216
+ const escape = (text) => text.replace(/[&<>"']/g, (char) => ESCAPES[char]);
217
+ /** The callback's answer: a small page that loads nothing, is never cached and leaks no referrer. */
218
+ function page(status, title, message) {
219
+ const html = `<!doctype html>
220
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
221
+ <title>${escape(title)} · Nylorun</title>
222
+ <style>body{font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:15vh auto;padding:0 1rem;color:#1a1a1a;background:#fff}@media (prefers-color-scheme:dark){body{color:#eee;background:#111}}h1{font-size:1.25rem}</style>
223
+ </head><body><h1>${escape(title)}</h1><p>${escape(message)}</p></body></html>
224
+ `;
225
+ return new Response(html, {
226
+ status,
227
+ headers: {
228
+ "content-type": "text/html; charset=utf-8",
229
+ "cache-control": "no-store",
230
+ "referrer-policy": "no-referrer",
231
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'",
232
+ "x-content-type-options": "nosniff",
233
+ },
234
+ });
167
235
  }
@@ -68,15 +68,9 @@ function hostRoutes() {
68
68
  return registry;
69
69
  }
70
70
  function tenantSchemes(registry) {
71
- registry.registerComponent("securitySchemes", "applicationKey", bearer("An application key of the Tenant, or a key derived from the admin key. With `Nylorun-Subject` and `Nylorun-Scopes`, it acts for that person, narrowed to those scopes."));
72
- registry.registerComponent("securitySchemes", "subjectToken", bearer("A subject token (`POST /v1/tokens`): one person and one role of the access policy, for at most 15 minutes.", "JWT"));
71
+ registry.registerComponent("securitySchemes", "applicationKey", bearer("An application key of the Tenant: an operator key (`PUT /v1/admin/keys/{id}`), or Studio's key, derived from the admin key. With `Nylorun-Subject` and `Nylorun-Scopes`, it acts for that person, narrowed to those scopes. Never accepted from a browser (`Origin`)."));
72
+ registry.registerComponent("securitySchemes", "issuerToken", bearer("A JWT from a trusted issuer of the Host's identity file (Host feature `trusted-issuers`): one person, with the issuer's scopes, agents and sandbox grants, until it expires. Accepted from servers and browsers alike; CORS is the operator's proxy's.", "JWT"));
73
73
  registry.registerComponent("securitySchemes", "deliveryToken", bearer("The delivery token the Runtime signs each Action delivery with (`Nylorun-Signature`), sent back by the Action endpoint. It reaches only that Action's heartbeat, result and sandbox routes, while that delivery is current, for at most 15 minutes.", "JWT"));
74
- registry.registerComponent("securitySchemes", "publishableKey", {
75
- type: "apiKey",
76
- in: "header",
77
- name: "Nylorun-Key",
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
- });
80
74
  }
81
75
  let tenant;
82
76
  let admin;
@@ -94,7 +88,7 @@ export function tenantDocument() {
94
88
  info: {
95
89
  title: "Nylorun Runtime: Tenant API",
96
90
  version: RUNTIME_VERSION,
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.",
91
+ 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` and `x-nylorun-scopes` (the subject scopes that reach it) fields.",
98
92
  "x-nylorun-protocol": PROTOCOL,
99
93
  },
100
94
  servers: [
@@ -122,7 +116,7 @@ export function adminDocument() {
122
116
  info: {
123
117
  title: "Nylorun Runtime: Admin API",
124
118
  version: RUNTIME_VERSION,
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.",
119
+ description: "The Host's status, its Tenant's state, shutdown and the Tenant's operator keys, with the admin key. Served on the operator listener when the Host has one; the public listener answers these routes with the opaque 404.",
126
120
  "x-nylorun-protocol": PROTOCOL,
127
121
  },
128
122
  servers: [
@@ -2,14 +2,14 @@
2
2
  * Capability links (protocol 6): a short-lived URL that opens one artifact version with no
3
3
  * other credential, for UIs, `<img>` tags and sharing. The token in the path is an ES256 JWT
4
4
  * (`typ: nylorun-artifact+jwt`) signed with the Tenant's current signing key by the keys
5
- * service, like subject and delivery tokens: `sub` is the artifact, `ver` the version, `aud`
6
- * `nylorun-artifact`, and it lives at most `ARTIFACT_LINK_MAX_TTL_SECONDS` (the subject-token
7
- * maximum, so a key rotation never revokes a live link). A link opens nothing once its artifact
5
+ * service, like delivery tokens: `sub` is the artifact, `ver` the version, `aud`
6
+ * `nylorun-artifact`, and it lives at most `ARTIFACT_LINK_MAX_TTL_SECONDS` (`TOKEN_TTL_MAX_SECONDS`,
7
+ * so a key rotation never revokes a live link). A link opens nothing once its artifact
8
8
  * is deleted. A folder's link (F8.2) opens its zip, or with a `path` claim one of its files.
9
9
  */
10
10
  import { randomUUID } from "node:crypto";
11
11
  import { errors, jwtVerify } from "jose";
12
- import { ARTIFACT_LINK_DEFAULT_TTL_SECONDS, ARTIFACT_LINK_MAX_TTL_SECONDS, ARTIFACT_LINK_TOKEN_TYPE, subjectTokenIssuer, } from "@nylorun/core/contracts";
12
+ import { ARTIFACT_LINK_DEFAULT_TTL_SECONDS, ARTIFACT_LINK_MAX_TTL_SECONDS, ARTIFACT_LINK_TOKEN_TYPE, tenantTokenIssuer, } from "@nylorun/core/contracts";
13
13
  import { isArtifactId } from "@nylorun/core/compatibility";
14
14
  import { failOpaque, HttpError } from "../tenant/http.js";
15
15
  import { CLOCK_TOLERANCE_SECONDS, tokenKeyId } from "../tenant/jwt.js";
@@ -24,7 +24,7 @@ export async function mintArtifactLink(ctx, link) {
24
24
  const { token } = await ctx.keys.sign({
25
25
  typ: ARTIFACT_LINK_TOKEN_TYPE,
26
26
  claims: {
27
- iss: subjectTokenIssuer(ctx.config.tenantId),
27
+ iss: tenantTokenIssuer(ctx.config.tenantId),
28
28
  aud: ARTIFACT_LINK_AUDIENCE,
29
29
  sub: link.artifactId,
30
30
  ver: link.version,
@@ -57,7 +57,7 @@ export async function verifyArtifactLink(ctx, raw) {
57
57
  try {
58
58
  const verified = await jwtVerify(raw, await ctx.signingKeys.publicKey(row), {
59
59
  algorithms: ["ES256"],
60
- issuer: subjectTokenIssuer(ctx.config.tenantId),
60
+ issuer: tenantTokenIssuer(ctx.config.tenantId),
61
61
  audience: ARTIFACT_LINK_AUDIENCE,
62
62
  typ: ARTIFACT_LINK_TOKEN_TYPE,
63
63
  clockTolerance: CLOCK_TOLERANCE_SECONDS,
@@ -11,7 +11,7 @@
11
11
  * module, after re-checking the Tenant total under the quota lock. Until it commits the object
12
12
  * is garbage, and a failed commit deletes it.
13
13
  * - **Access mirrors sessions.** An application principal reaches every artifact. A request acting
14
- * for a person (a subject token or subject headers) reaches only artifacts of that person's
14
+ * for a person (a trusted issuer's token or subject headers) reaches only artifacts of that person's
15
15
  * sessions; another person's, or a Tenant-wide one, is the same 404 as a missing artifact.
16
16
  *
17
17
  * Lock order, the same in every write: the session, then the quota lock, then the artifact row.
@@ -38,7 +38,11 @@ export function createMcpHandler(options) {
38
38
  const manifest = await declared(vault, server);
39
39
  const connection = await open({
40
40
  server: manifest,
41
- authorize: (url) => vault.authorizeMcp(server.sessionId, { url, serverName: server.serverName }),
41
+ authorize: (url) => vault.authorizeMcp(server.sessionId, {
42
+ url,
43
+ serverName: server.serverName,
44
+ ...(server.agentId === undefined ? {} : { agentId: server.agentId }),
45
+ }),
42
46
  pluginData: "",
43
47
  });
44
48
  const entry = { connection, lastUsedAt: now(), active: 0 };
@@ -9,10 +9,12 @@
9
9
  import type { Credential } from "@earendil-works/pi-ai";
10
10
  import type { ModelFailureOutcome } from "@nylorun/core/define";
11
11
  import type { PostgresClient } from "../store/postgres/connect.js";
12
- import { VaultService, type AuthorizeResult, type HostModelSecret } from "../vault/service.js";
12
+ import { type AuthorizeResult, type HostModelSecret } from "../vault/service.js";
13
+ import { CredentialSources, type McpCredentialRequest, type ResolverConfig } from "../vault/sources.js";
13
14
  import type { SessionStore } from "../store/types.js";
14
15
  import type { Session } from "../tenant/context.js";
15
16
  import { type Keys } from "../keys/keys.js";
17
+ import { type OutboundPolicy } from "../tenant/outbound.js";
16
18
  /** The Tenant, as a model call needs it. */
17
19
  export interface TenantVault {
18
20
  readonly tenantId: string;
@@ -26,12 +28,14 @@ export interface TenantVault {
26
28
  /** A session, for the remote MCP servers its pinned manifest declares (F4.1). */
27
29
  session(sessionId: string): Promise<Session | undefined>;
28
30
  /**
29
- * The vault authorization of one request to a session's remote MCP server: its credential
30
- * from the session's attached vaults, refreshed when due (F4.1).
31
+ * The authorization of one request to a session's remote MCP server: its credential from the
32
+ * session's attached vaults, refreshed when due (F4.1), else from the operator's credential
33
+ * resolver (F9 C1).
31
34
  */
32
35
  authorizeMcp(sessionId: string, request: {
33
36
  url: string;
34
37
  serverName: string;
38
+ agentId?: string;
35
39
  }): Promise<AuthorizeResult>;
36
40
  /** Vault writes and token signing with the Tenant's vault key (the keys service, F4.2). */
37
41
  keys(): Keys;
@@ -52,13 +56,18 @@ export interface TenantVaultsOptions {
52
56
  readonly sql: PostgresClient;
53
57
  /** The Host root; the gate reads `keys/vault-kek` and `tenant/home` under it. */
54
58
  readonly hostRoot: string;
59
+ /** The operator's credential resolver (`NYLORUN_RESOLVER_*`, F9 C1). */
60
+ readonly resolver?: ResolverConfig;
61
+ /**
62
+ * The Host's address policy (`NYLORUN_ENDPOINT_*`): OAuth refresh and MCP OAuth connect call
63
+ * authorization servers through `guardedFetch` under it (F9 C2).
64
+ */
65
+ readonly delivery?: OutboundPolicy;
55
66
  }
56
67
  export declare function createTenantVaults(options: TenantVaultsOptions): TenantVaults;
57
68
  /**
58
- * A request to a session's remote MCP server, authorized from the session's attached vaults:
59
- * what the loop did in its own process before F4.1 (`tenant/effects.ts` `authorize`).
69
+ * A request to a session's remote MCP server, authorized from the session's attached vaults,
70
+ * else the operator's credential resolver: what the loop does in its own process
71
+ * (`tenant/effects.ts` `authorize`). The owner and turn come from the session row.
60
72
  */
61
- export declare function authorizeSessionMcp(credentials: VaultService, session: (sessionId: string) => Promise<Session | undefined>, sessionId: string, request: {
62
- url: string;
63
- serverName: string;
64
- }): Promise<AuthorizeResult>;
73
+ export declare function authorizeSessionMcp(sources: CredentialSources, session: (sessionId: string) => Promise<Session | undefined>, sessionId: string, request: McpCredentialRequest): Promise<AuthorizeResult>;
@@ -8,8 +8,10 @@ import { tenantPaths } from "../tenant/paths.js";
8
8
  import { readVaultKek } from "../vault/kek.js";
9
9
  import { HostModelVault } from "../vault/host-model.js";
10
10
  import { VaultService } from "../vault/service.js";
11
+ import { CredentialSources } from "../vault/sources.js";
11
12
  import { inProcessKeys } from "../keys/keys.js";
12
13
  import { SigningKeys } from "../tenant/signing-keys.js";
14
+ import { guardedFetch } from "../tenant/outbound.js";
13
15
  /** A call the gate refuses before the provider is called; it answers with `outcome`. */
14
16
  export class GateRefusal extends Error {
15
17
  outcome;
@@ -33,7 +35,15 @@ export function createTenantVaults(options) {
33
35
  return kek;
34
36
  };
35
37
  const vault = new HostModelVault({ store, kek: readKek });
36
- const credentials = new VaultService({ store, kek: readKek, fetch: globalThis.fetch });
38
+ const credentials = new VaultService({
39
+ store,
40
+ kek: readKek,
41
+ fetch: guardedFetch(options.delivery ?? {}),
42
+ });
43
+ const sources = new CredentialSources({
44
+ vault: credentials,
45
+ ...(options.resolver ? { resolver: options.resolver } : {}),
46
+ });
37
47
  const keys = inProcessKeys({
38
48
  store,
39
49
  vault: credentials,
@@ -48,7 +58,7 @@ export function createTenantVaults(options) {
48
58
  readHostModel: () => vault.readHostModel(),
49
59
  writeHostCredential: (credential) => vault.updateHostCredential(credential),
50
60
  session,
51
- authorizeMcp: (sessionId, request) => authorizeSessionMcp(credentials, session, sessionId, request),
61
+ authorizeMcp: (sessionId, request) => authorizeSessionMcp(sources, session, sessionId, request),
52
62
  keys: () => keys,
53
63
  };
54
64
  }
@@ -77,18 +87,13 @@ export function createTenantVaults(options) {
77
87
  };
78
88
  }
79
89
  /**
80
- * A request to a session's remote MCP server, authorized from the session's attached vaults:
81
- * what the loop did in its own process before F4.1 (`tenant/effects.ts` `authorize`).
90
+ * A request to a session's remote MCP server, authorized from the session's attached vaults,
91
+ * else the operator's credential resolver: what the loop does in its own process
92
+ * (`tenant/effects.ts` `authorize`). The owner and turn come from the session row.
82
93
  */
83
- export async function authorizeSessionMcp(credentials, session, sessionId, request) {
94
+ export async function authorizeSessionMcp(sources, session, sessionId, request) {
84
95
  const found = await session(sessionId);
85
96
  if (!found)
86
97
  throw new Error(`Session ${sessionId} not found`);
87
- return credentials.authorize({
88
- sessionId,
89
- vaultIds: found.vaultIds ?? [],
90
- credentialSelections: found.credentialSelections ?? [],
91
- url: request.url,
92
- serverName: request.serverName,
93
- });
98
+ return sources.authorize({ ...found, id: sessionId }, request);
94
99
  }
@@ -1,12 +1,14 @@
1
1
  /**
2
- * The Admin API (`/v1/admin/**`): the Host's status and shutdown, for a caller with the admin
3
- * key. A Host serves one Tenant, so there are no Tenant routes: the status names the Tenant
4
- * and, when it could not be opened, why (tenancy.md §9). The Host checks the listener, the
5
- * protocol and the admin key first (`app.ts`); what gets here is authorized. Routes are
6
- * declared once, for serving and for the OpenAPI document.
2
+ * The Admin API (`/v1/admin/**`): the Host's status and shutdown, and the Tenant's operator
3
+ * keys (F9 I1), for a caller with the admin key. A Host serves one Tenant, so there are no
4
+ * Tenant routes: the status names the Tenant and, when it could not be opened, why
5
+ * (tenancy.md §9). The Host checks the listener, the protocol and the admin key first
6
+ * (`app.ts`); what gets here is authorized. Routes are declared once, for serving and for the
7
+ * OpenAPI document.
7
8
  */
8
9
  import { OpenAPIHono } from "@hono/zod-openapi";
9
10
  import type { AdminStatus } from "@nylorun/core/contracts";
11
+ import type { OperatorKeys } from "../tenant/operator-keys.js";
10
12
  import type { NodeBindings } from "../tenant/types.js";
11
13
  export interface AdminApiOptions {
12
14
  /** `GET /v1/admin/status`. */
@@ -15,6 +17,11 @@ export interface AdminApiOptions {
15
17
  shutdown(): void;
16
18
  /** `GET /v1/admin/openapi.json`: this API's OpenAPI document (`api/openapi.ts`). */
17
19
  document?(): unknown;
20
+ /**
21
+ * The open Tenant's operator keys (`/v1/admin/keys`); undefined while the Tenant is not open.
22
+ * Rejects with `TenantUnavailableError` (503) when it cannot be reached.
23
+ */
24
+ operatorKeys?(): Promise<OperatorKeys | undefined>;
18
25
  }
19
26
  type AdminEnv = {
20
27
  Bindings: NodeBindings;
@@ -1,13 +1,14 @@
1
1
  /**
2
- * The Admin API (`/v1/admin/**`): the Host's status and shutdown, for a caller with the admin
3
- * key. A Host serves one Tenant, so there are no Tenant routes: the status names the Tenant
4
- * and, when it could not be opened, why (tenancy.md §9). The Host checks the listener, the
5
- * protocol and the admin key first (`app.ts`); what gets here is authorized. Routes are
6
- * declared once, for serving and for the OpenAPI document.
2
+ * The Admin API (`/v1/admin/**`): the Host's status and shutdown, and the Tenant's operator
3
+ * keys (F9 I1), for a caller with the admin key. A Host serves one Tenant, so there are no
4
+ * Tenant routes: the status names the Tenant and, when it could not be opened, why
5
+ * (tenancy.md §9). The Host checks the listener, the protocol and the admin key first
6
+ * (`app.ts`); what gets here is authorized. Routes are declared once, for serving and for the
7
+ * OpenAPI document.
7
8
  */
8
9
  import { OpenAPIHono } from "@hono/zod-openapi";
9
10
  import { z } from "zod";
10
- import { AdminStatus as AdminStatusBody, HostShutdownResponse, ProtocolRejected, Rejected, } from "../api/components.js";
11
+ import { AdminStatus as AdminStatusBody, DeleteOperatorKeyResponse, HostShutdownResponse, ListOperatorKeysResponse, PutOperatorKeyResponse, ProtocolRejected, Rejected, } from "../api/components.js";
11
12
  import { serveRoute } from "../api/route.js";
12
13
  import { jsonResponse, rejectedResponse } from "./http.js";
13
14
  const json = (schema, description) => ({
@@ -54,6 +55,68 @@ export function createAdminApi(options) {
54
55
  c.env.outgoing.once("finish", () => options.shutdown());
55
56
  return jsonResponse(200, { status: "shutting_down" });
56
57
  });
58
+ const keyId = z.object({
59
+ id: z.string().meta({ description: "The key's name: `^[a-z][a-z0-9-]{0,31}$`, never `studio`" }),
60
+ });
61
+ const keysOf = async () => (await options.operatorKeys?.()) ??
62
+ rejectedResponse(503, "request_rejected", "The Tenant is not open: see /v1/admin/status");
63
+ serveRoute(api, adminRoute({
64
+ method: "get",
65
+ path: "/v1/admin/keys",
66
+ summary: "List the Tenant's keys",
67
+ description: "Every application key of the Tenant by name, with its role and when it was issued. Keys themselves are never shown again.",
68
+ responses: {
69
+ 200: json(ListOperatorKeysResponse, "The keys, by id"),
70
+ 503: rejected("The Tenant is not open"),
71
+ },
72
+ }), async () => {
73
+ const keys = await keysOf();
74
+ if (keys instanceof Response)
75
+ return keys;
76
+ return jsonResponse(200, { keys: await keys.list() });
77
+ });
78
+ serveRoute(api, adminRoute({
79
+ method: "put",
80
+ path: "/v1/admin/keys/{id}",
81
+ summary: "Create or rotate a key",
82
+ description: "Issues a new key named `id` and returns it this once. A key of that name is replaced: the old one stops authenticating at once. `studio` is refused.",
83
+ request: { params: keyId },
84
+ responses: {
85
+ 200: json(PutOperatorKeyResponse, "The new key, shown once"),
86
+ 400: rejected("Not a key name, or `studio`"),
87
+ 503: rejected("The Tenant is not open"),
88
+ },
89
+ }), async (c) => {
90
+ const keys = await keysOf();
91
+ if (keys instanceof Response)
92
+ return keys;
93
+ const put = await keys.put(c.req.param("id") ?? "");
94
+ return "reason" in put ? refusedResponse(put) : jsonResponse(200, put);
95
+ });
96
+ serveRoute(api, adminRoute({
97
+ method: "delete",
98
+ path: "/v1/admin/keys/{id}",
99
+ summary: "Delete a key",
100
+ description: "The key stops authenticating at once. `studio` is refused.",
101
+ request: { params: keyId },
102
+ responses: {
103
+ 200: json(DeleteOperatorKeyResponse, "The key is deleted"),
104
+ 400: rejected("Not a key name, or `studio`"),
105
+ 404: rejected("No key of that name; or an unknown admin key, or the public listener"),
106
+ 503: rejected("The Tenant is not open"),
107
+ },
108
+ }), async (c) => {
109
+ const keys = await keysOf();
110
+ if (keys instanceof Response)
111
+ return keys;
112
+ const id = c.req.param("id") ?? "";
113
+ const deleted = await keys.delete(id);
114
+ if (typeof deleted !== "boolean")
115
+ return refusedResponse(deleted);
116
+ return deleted
117
+ ? jsonResponse(200, { id, deleted: true })
118
+ : rejectedResponse(404, "not_found", `No key ${id}`);
119
+ });
57
120
  serveRoute(api, adminRoute({
58
121
  method: "get",
59
122
  path: "/v1/admin/openapi.json",
@@ -72,3 +135,6 @@ export function createAdminApi(options) {
72
135
  function notFound() {
73
136
  return rejectedResponse(404, "not_found", "Route not found");
74
137
  }
138
+ function refusedResponse(refusal) {
139
+ return rejectedResponse(400, refusal.reason === "invalid" ? "invalid_request" : "request_rejected", refusal.message);
140
+ }
@@ -20,7 +20,6 @@ export interface HostAppOptions {
20
20
  adminKey: string;
21
21
  coreVersion: string;
22
22
  pid: number;
23
- browserAccess: boolean;
24
23
  readiness?: () => Promise<{
25
24
  ok: boolean;
26
25
  checks: Record<string, boolean>;