@nylorun/runtime 0.19.1-beta → 0.20.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 (61) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +9 -5
  3. package/dist/adapters/streams/s2.js +5 -11
  4. package/dist/api/components.d.ts +171 -169
  5. package/dist/api/http/define.d.ts +3 -2
  6. package/dist/api/http/define.js +10 -11
  7. package/dist/api/openapi.js +31 -3
  8. package/dist/execution/types.d.ts +1 -1
  9. package/dist/host/app.d.ts +3 -0
  10. package/dist/host/app.js +33 -4
  11. package/dist/host/create-host.d.ts +6 -0
  12. package/dist/host/create-host.js +1 -0
  13. package/dist/host/main.js +11 -4
  14. package/dist/host/stack-config.js +1 -1
  15. package/dist/infra/index.d.ts +2 -1
  16. package/dist/infra/index.js +0 -1
  17. package/dist/infra/readiness.d.ts +6 -6
  18. package/dist/infra/readiness.js +2 -5
  19. package/dist/management-openapi.json +430 -107
  20. package/dist/openapi.json +366 -227
  21. package/dist/store/postgres/connect.d.ts +10 -0
  22. package/dist/store/postgres/connect.js +30 -3
  23. package/dist/store/postgres/control.d.ts +14 -0
  24. package/dist/store/postgres/control.js +187 -0
  25. package/dist/store/postgres/drizzle/0015_control_signals.sql +11 -0
  26. package/dist/store/postgres/drizzle/meta/_journal.json +7 -0
  27. package/dist/store/postgres/schema.d.ts +120 -0
  28. package/dist/store/postgres/schema.js +20 -0
  29. package/dist/store/postgres/store.js +15 -0
  30. package/dist/store/types.d.ts +40 -0
  31. package/dist/streams/types.d.ts +6 -29
  32. package/dist/streams/types.js +5 -4
  33. package/dist/tenant/auth.d.ts +16 -6
  34. package/dist/tenant/auth.js +41 -17
  35. package/dist/tenant/commands.js +10 -6
  36. package/dist/tenant/context.d.ts +4 -0
  37. package/dist/tenant/control.d.ts +26 -0
  38. package/dist/tenant/control.js +35 -0
  39. package/dist/tenant/ephemeral.js +1 -0
  40. package/dist/tenant/http.d.ts +4 -2
  41. package/dist/tenant/http.js +4 -2
  42. package/dist/tenant/identity-file.d.ts +16 -12
  43. package/dist/tenant/identity-file.js +41 -29
  44. package/dist/tenant/issuers.d.ts +4 -3
  45. package/dist/tenant/issuers.js +22 -16
  46. package/dist/tenant/reset.d.ts +2 -2
  47. package/dist/tenant/reset.js +8 -2
  48. package/dist/tenant/resource-server.d.ts +56 -0
  49. package/dist/tenant/resource-server.js +62 -0
  50. package/dist/tenant/runtime.d.ts +1 -1
  51. package/dist/tenant/runtime.js +7 -1
  52. package/dist/tenant/scheduler.d.ts +1 -1
  53. package/dist/tenant/scheduler.js +1 -1
  54. package/dist/tenant/streams.d.ts +16 -22
  55. package/dist/tenant/streams.js +7 -87
  56. package/dist/tenant/sweep.js +9 -1
  57. package/dist/version.d.ts +1 -1
  58. package/dist/version.js +1 -1
  59. package/package.json +3 -3
  60. package/dist/streams/control.d.ts +0 -13
  61. package/dist/streams/control.js +0 -25
@@ -1,9 +1,10 @@
1
1
  import { SCOPES_HEADER, SUBJECT_HEADER, } from "@nylorun/core/compatibility";
2
- import { parseSubjectHeaders } from "@nylorun/core/contracts";
2
+ import { parseSubjectHeaders, TOKEN_SCOPES } from "@nylorun/core/contracts";
3
3
  import { hashToken } from "../core/bearer.js";
4
- import { fail, failOpaque } from "./http.js";
4
+ import { fail } from "./http.js";
5
5
  import { verifyIssuerToken } from "./issuers.js";
6
6
  import { looksLikeToken } from "./jwt.js";
7
+ import { bearerChallenge, failCredential, protectedResourceMetadataUrl } from "./resource-server.js";
7
8
  const SUBJECT_INVALID = { code: "subject_invalid" };
8
9
  /** One header's value, or `undefined`; a header sent twice is refused. */
9
10
  function singleHeader(request, name) {
@@ -18,20 +19,30 @@ function singleHeader(request, name) {
18
19
  }
19
20
  /** Every route that does not say otherwise is a Runtime API route. */
20
21
  export const RUNTIME_KEYS = { application: true, management: false };
22
+ /** The bearer of an `Authorization` header: the scheme is case-insensitive (RFC 9110 §11.1). */
23
+ const BEARER = /^bearer +(\S+) *$/i;
24
+ /**
25
+ * The `resource_metadata` a route's challenges point at: a Runtime API route's, when the Runtime
26
+ * trusts issuers. The Management API is no OAuth resource.
27
+ */
28
+ export function challengeMetadata(ctx, request, keys) {
29
+ return keys.application ? protectedResourceMetadataUrl(ctx.config, request) : undefined;
30
+ }
21
31
  export async function authenticate(ctx, request, keys = RUNTIME_KEYS) {
22
- const header = request.headers.authorization;
23
- const token = header?.startsWith("Bearer ") ? header.slice(7) : "";
24
- if (!token || !header?.startsWith("Bearer ")) {
32
+ const metadata = challengeMetadata(ctx, request, keys);
33
+ const token = BEARER.exec(request.headers.authorization ?? "")?.[1];
34
+ // No credential, or another scheme: the client may not know it needs one (no error code).
35
+ if (!token) {
25
36
  ctx.config.logger.warn("credential rejected", {
26
37
  reason: "missing_bearer",
27
38
  });
28
- return failOpaque();
39
+ return failCredential("required", metadata);
29
40
  }
30
41
  // A trusted issuer's token (Host feature `trusted-issuers`), from a server or a browser: its
31
42
  // unverified `iss` names an issuer of the identity file, and only that issuer verifies it.
32
43
  const issuer = ctx.config.issuers?.claimed(token);
33
44
  if (issuer) {
34
- const scope = await verifyIssuerToken(ctx, issuer, token);
45
+ const scope = await verifyIssuerToken(ctx, issuer, token, metadata);
35
46
  if (singleHeader(request, SUBJECT_HEADER) !== undefined ||
36
47
  singleHeader(request, SCOPES_HEADER) !== undefined)
37
48
  fail(403, "An issuer token cannot act for another subject");
@@ -40,7 +51,7 @@ export async function authenticate(ctx, request, keys = RUNTIME_KEYS) {
40
51
  // Any other JWT: no issuer of this Host signed it (subject tokens are gone, protocol 7).
41
52
  if (looksLikeToken(token)) {
42
53
  ctx.config.logger.warn("credential rejected", { reason: "token_unknown_issuer" });
43
- return failOpaque();
54
+ return failCredential("invalid", metadata);
44
55
  }
45
56
  // Application keys never come from a browser or a shipped app: refused before they are even
46
57
  // looked up.
@@ -54,9 +65,9 @@ export async function authenticate(ctx, request, keys = RUNTIME_KEYS) {
54
65
  ctx.config.logger.warn("credential rejected", {
55
66
  reason: "unknown_token",
56
67
  });
57
- return failOpaque();
68
+ return failCredential("invalid", metadata);
58
69
  }
59
- // Read only after authentication, so an unknown caller sees the opaque 404 either way.
70
+ // Read only after authentication, so an unknown caller learns nothing of them.
60
71
  const subject = singleHeader(request, SUBJECT_HEADER);
61
72
  const scopes = singleHeader(request, SCOPES_HEADER);
62
73
  // A management key acts as itself on the Management API; Studio's key does too on a route
@@ -92,9 +103,13 @@ export async function authenticate(ctx, request, keys = RUNTIME_KEYS) {
92
103
  }
93
104
  /**
94
105
  * A subject or a token caller reaches a route only with one of the scopes it declares:
95
- * `403 scope_required`, decided from the route alone before anything is read.
106
+ * `403 scope_required`, decided from the route alone before anything is read. A token caller
107
+ * also gets the OAuth challenge (`error="insufficient_scope"` and the scopes that would do), so
108
+ * its client can ask its identity provider for them; a route no scope reaches has none.
96
109
  */
97
- export function requireScopes(scope, access) {
110
+ export function requireScopes(scope, access,
111
+ /** The route's `resource_metadata` (`challengeMetadata`), for a token caller's challenge. */
112
+ resourceMetadata) {
98
113
  if (scope.kind !== "subject" && scope.kind !== "token")
99
114
  return;
100
115
  if (access === "any")
@@ -105,11 +120,20 @@ export function requireScopes(scope, access) {
105
120
  details: { scopes: [] },
106
121
  });
107
122
  const held = scope.scopes;
108
- if (!access.some((name) => held.has(name)))
109
- fail(403, `Scope ${access.join(" or ")} required`, {
110
- code: "scope_required",
111
- details: { scopes: access },
112
- });
123
+ if (!access.some((name) => held.has(name))) {
124
+ // Any one of the route's scopes reaches it, but OAuth's list is of scopes held together:
125
+ // the challenge names the first a token can carry, or none when no token reaches the route.
126
+ const askable = access.find((name) => TOKEN_SCOPES.includes(name));
127
+ fail(403, `Scope ${access.join(" or ")} required`, { code: "scope_required", details: { scopes: access } }, scope.kind === "token" && askable
128
+ ? {
129
+ "www-authenticate": bearerChallenge({
130
+ error: "insufficient_scope",
131
+ scope: askable,
132
+ resourceMetadata,
133
+ }),
134
+ }
135
+ : {});
136
+ }
113
137
  }
114
138
  export function accessOf(scope) {
115
139
  switch (scope.kind) {
@@ -10,9 +10,10 @@
10
10
  * (every accepted one writes a new checkpoint segment), except that a workflow's `approve` and
11
11
  * `respond` name `<type>:<turnId>:<interactionId>`: a flow resumes in the same segment.
12
12
  *
13
- * Cancel commits `cancelled` first; the engine host sees it before its next effect and before
14
- * settlement on any Worker. It then aborts an advance running on this process
15
- * (`ctx.abortLocal`); reaching an advance on another process is the control stream's job.
13
+ * Cancel commits `cancelled` first, with a `session.cancel` signal on the control bus in the
14
+ * same transaction; the engine host sees it before its next effect and before settlement on
15
+ * any Worker. It then aborts an advance running on this process (`ctx.abortLocal`); every
16
+ * other process aborts its own on the signal (`control.ts`).
16
17
  */
17
18
  import { randomUUID } from "node:crypto";
18
19
  import { createDurableCheckpoint, createFlowCheckpoint, resumeFlowCheckpoint, } from "@nylorun/harness/run";
@@ -24,7 +25,6 @@ import { fail } from "./http.js";
24
25
  import { accessOf } from "./auth.js";
25
26
  import { checkSandboxTurn } from "./sandboxes.js";
26
27
  import { rebaseSessionState, turnManifestOf, variantStore } from "./session.js";
27
- import { signalSessionCancel } from "./streams.js";
28
28
  import { slimModelEffects } from "./slim.js";
29
29
  import { resolveMessageParts } from "../artifacts/parts.js";
30
30
  /** A command's identity for idempotency: everything but the request id. */
@@ -83,8 +83,12 @@ export async function command(ctx, id, input, scope) {
83
83
  reason: command.reason,
84
84
  });
85
85
  await slimModelEffects(t, id, cancelledTurnId);
86
- // The process running the advance aborts it on `session.cancel` (tenant/control).
87
- t.afterCommit(() => signalSessionCancel(ctx, id, cancelledTurnId));
86
+ // The process running the advance aborts it on this signal (control.ts), sent at commit.
87
+ await t.signal({
88
+ type: "session.cancel",
89
+ sessionId: id,
90
+ ...(cancelledTurnId !== null ? { turnId: cancelledTurnId } : {}),
91
+ });
88
92
  s.activeTurnId = null;
89
93
  if (cancelledTurnId !== null)
90
94
  s.lastTurnId = cancelledTurnId;
@@ -201,6 +201,10 @@ export interface TenantContext {
201
201
  readonly work: WorkState;
202
202
  /** Live delivery over Durable Streams: one `SessionStream` per observed session, and the streams wiring. */
203
203
  readonly sessionStreams: SessionStreams;
204
+ /** This process's follower of the control bus (`control.ts`), once wired. */
205
+ control?: {
206
+ close(): Promise<void>;
207
+ };
204
208
  /** The Tenant's signing keys: capability links, run and host tokens. */
205
209
  readonly signingKeys: SigningKeys;
206
210
  /** The Worker id this process writes as session `owner` (§10.6). */
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The control bus of an open Tenant (blueprint D21, D48): the signals one process sends the
3
+ * others with the Tenant open, on Postgres (`Tx.signal`, `SessionStore.followSignals`). They
4
+ * never go through S2, which only serves API listeners.
5
+ *
6
+ * - `session.cancel` is written by the cancel's own transaction (`commands.ts`): every process
7
+ * aborts the cancelled turn's advance if it runs there (`ctx.abortLocal`).
8
+ * - `sessions.reset` is written by the reset's transaction (`reset.ts`): every process moves
9
+ * its session streams to the new basin generation and ends those of deleted sessions
10
+ * (`checkSessionStreams`).
11
+ *
12
+ * A lost signal costs latency, never correctness: the advance checks the Session Store before
13
+ * every effect, and the session streams are checked periodically (`streams.ts`).
14
+ */
15
+ import type { TenantContext } from "./context.js";
16
+ export interface WireControlOptions {
17
+ /** How often the follower reads recent signals back (`FollowSignalsOptions.pollMs`). */
18
+ pollMs?: number;
19
+ }
20
+ /**
21
+ * Follows the control bus for this process and keeps the follower on `ctx.control`. Call it
22
+ * once, after `ctx` is built; signals committed from its return on reach this process.
23
+ */
24
+ export declare function wireControl(ctx: TenantContext, options?: WireControlOptions): Promise<void>;
25
+ /** Close: stops following the control bus. */
26
+ export declare function closeControl(ctx: TenantContext): Promise<void>;
@@ -0,0 +1,35 @@
1
+ import { checkSessionStreams } from "./session-streams.js";
2
+ const messageOf = (error) => error instanceof Error ? error.message : String(error);
3
+ /**
4
+ * Follows the control bus for this process and keeps the follower on `ctx.control`. Call it
5
+ * once, after `ctx` is built; signals committed from its return on reach this process.
6
+ */
7
+ export async function wireControl(ctx, options = {}) {
8
+ if (ctx.control)
9
+ throw new Error("The control bus is already wired");
10
+ let closed = false;
11
+ // Nothing is reported once closed: the Tenant (and its log directory) may be gone.
12
+ const report = (what) => (error) => {
13
+ if (!closed)
14
+ ctx.config.logger.warn(what, { message: messageOf(error) });
15
+ };
16
+ const follower = await ctx.store.followSignals((signal) => {
17
+ if (signal.type === "session.cancel")
18
+ ctx.abortLocal(signal.sessionId, signal.turnId);
19
+ else
20
+ void checkSessionStreams(ctx).catch(report("session stream check failed"));
21
+ }, {
22
+ ...(options.pollMs !== undefined ? { pollMs: options.pollMs } : {}),
23
+ onError: report("control bus unavailable; retrying"),
24
+ });
25
+ ctx.control = {
26
+ async close() {
27
+ closed = true;
28
+ await follower.close();
29
+ },
30
+ };
31
+ }
32
+ /** Close: stops following the control bus. */
33
+ export async function closeControl(ctx) {
34
+ await ctx.control?.close();
35
+ }
@@ -145,6 +145,7 @@ export async function startEphemeralRuntime(options) {
145
145
  config: hostConfig,
146
146
  logger,
147
147
  coreVersion: coreVersion(),
148
+ ...(issuers ? { resourceServer: { issuers } } : {}),
148
149
  });
149
150
  // Opens the Tenant: creates it in the database first when it holds none.
150
151
  await host.listen();
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * The Tenant's typed rejections: what a route or the code it calls throws for the client to
3
- * see (`api/http/respond.ts` answers it), and the opaque 404 for unknown credentials (D5).
3
+ * see (`api/http/respond.ts` answers it), and the opaque 404 (D5) for what must not be told
4
+ * apart from nothing: a capability link the Runtime did not sign, a Tenant that is not this
5
+ * Host's. Credentials are `401` with a challenge since protocol 9 (`resource-server.ts`).
4
6
  *
5
7
  * Later waves: stable; the streams seam (Wave 2 / Y) keeps using these helpers.
6
8
  */
7
- /** D5 opaque failure for unknown/rejected credentials on Tenant routes. */
9
+ /** D5's opaque failure: a 404 that says nothing of why. */
8
10
  export declare const OPAQUE_NOT_FOUND: {
9
11
  status: "rejected";
10
12
  code: "not_found";
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * The Tenant's typed rejections: what a route or the code it calls throws for the client to
3
- * see (`api/http/respond.ts` answers it), and the opaque 404 for unknown credentials (D5).
3
+ * see (`api/http/respond.ts` answers it), and the opaque 404 (D5) for what must not be told
4
+ * apart from nothing: a capability link the Runtime did not sign, a Tenant that is not this
5
+ * Host's. Credentials are `401` with a challenge since protocol 9 (`resource-server.ts`).
4
6
  *
5
7
  * Later waves: stable; the streams seam (Wave 2 / Y) keeps using these helpers.
6
8
  */
7
- /** D5 opaque failure for unknown/rejected credentials on Tenant routes. */
9
+ /** D5's opaque failure: a 404 that says nothing of why. */
8
10
  export const OPAQUE_NOT_FOUND = {
9
11
  status: "rejected",
10
12
  code: "not_found",
@@ -2,20 +2,22 @@
2
2
  * The identity file (Host feature `trusted-issuers`, F9 I2): the YAML file
3
3
  * `NYLORUN_IDENTITY_FILE` names, listing the issuers whose JWTs the Tenant API accepts as
4
4
  * bearers. Read once at boot (`host/main.ts`); a change takes a restart. A malformed file stops
5
- * the boot with a message naming the issuer and the field.
5
+ * the boot with a message naming the issuer and the field. A key the file does not define is
6
+ * ignored with a warning (`maxLifetime`, gone in protocol 9, among them), so an older file
7
+ * still boots.
6
8
  *
7
9
  * ```yaml
8
10
  * issuers:
9
11
  * - name: keycloak
10
12
  * issuer: https://sso.acme.dev/realms/eng
11
- * audience: nylorun
13
+ * audience: https://agents.acme.dev
12
14
  * jwks: https://sso.acme.dev/realms/eng/protocol/openid-connect/certs # or keys: [<PEM>]
13
- * subject: "u:{sub}" # scalar claims only
14
- * scopes: { claim: nylorun_scopes } # or { fixed: [sessions:own, agents:read] }
15
- * allowedScopes: [agents:read, sessions:own, sandboxes:write, studio]
16
- * agents: [support] # optional allowlist; absent means all
17
- * sandboxes: ["{org_id}/*"] # optional grants; absent reaches none
18
- * maxLifetime: 15m
15
+ * # Optional, with their defaults:
16
+ * subject: "{sub}" # scalar claims only
17
+ * scopes: { claim: scope } # or { fixed: [sessions:own, agents:read] }
18
+ * allowedScopes: [agents:read, sessions:own, sandboxes:write] # add studio to grant it
19
+ * agents: [support] # absent reaches every agent
20
+ * sandboxes: ["{org_id}/*"] # absent reaches no sandbox
19
21
  * ```
20
22
  */
21
23
  import { type KeyObject } from "node:crypto";
@@ -41,7 +43,7 @@ export interface TrustedIssuerConfig {
41
43
  readonly jwks?: string;
42
44
  /** Its public keys, given in the file (PEM); or `jwks`. */
43
45
  readonly keys?: readonly IssuerKey[];
44
- /** The subject template, such as `u:{sub}`: at least one claim reference. */
46
+ /** The subject template, such as `{sub}` (the default): at least one claim reference. */
45
47
  readonly subject: string;
46
48
  /** Where its tokens' scopes come from: a claim, or a fixed list. */
47
49
  readonly scopes: {
@@ -55,8 +57,6 @@ export interface TrustedIssuerConfig {
55
57
  readonly agents?: readonly string[];
56
58
  /** Sandbox grant templates, such as `{org_id}/*`; absent reaches no sandbox. */
57
59
  readonly sandboxes?: readonly string[];
58
- /** The longest `exp - iat` it may sign, in seconds. */
59
- readonly maxLifetimeSeconds: number;
60
60
  }
61
61
  /** A malformed identity file: the message names the issuer and the field. */
62
62
  export declare class IdentityFileError extends Error {
@@ -68,8 +68,12 @@ export declare function claimReferences(template: string): string[];
68
68
  export declare function renderTemplate(template: string, value: (claim: string) => string | undefined): string | undefined;
69
69
  /** A PEM public key an issuer may sign with, and its algorithm; throws a message otherwise. */
70
70
  export declare function issuerKey(key: KeyObject, kid?: string): IssuerKey;
71
+ export interface ParseIdentityFileOptions {
72
+ /** Told of every key the file has but does not define, which is ignored. */
73
+ readonly warn?: (message: string) => void;
74
+ }
71
75
  /**
72
76
  * The issuers of an identity file's text. Throws `IdentityFileError` naming the file, the issuer
73
77
  * and the field when the file is malformed.
74
78
  */
75
- export declare function parseIdentityFile(text: string, source?: string): TrustedIssuerConfig[];
79
+ export declare function parseIdentityFile(text: string, source?: string, options?: ParseIdentityFileOptions): TrustedIssuerConfig[];
@@ -2,31 +2,34 @@
2
2
  * The identity file (Host feature `trusted-issuers`, F9 I2): the YAML file
3
3
  * `NYLORUN_IDENTITY_FILE` names, listing the issuers whose JWTs the Tenant API accepts as
4
4
  * bearers. Read once at boot (`host/main.ts`); a change takes a restart. A malformed file stops
5
- * the boot with a message naming the issuer and the field.
5
+ * the boot with a message naming the issuer and the field. A key the file does not define is
6
+ * ignored with a warning (`maxLifetime`, gone in protocol 9, among them), so an older file
7
+ * still boots.
6
8
  *
7
9
  * ```yaml
8
10
  * issuers:
9
11
  * - name: keycloak
10
12
  * issuer: https://sso.acme.dev/realms/eng
11
- * audience: nylorun
13
+ * audience: https://agents.acme.dev
12
14
  * jwks: https://sso.acme.dev/realms/eng/protocol/openid-connect/certs # or keys: [<PEM>]
13
- * subject: "u:{sub}" # scalar claims only
14
- * scopes: { claim: nylorun_scopes } # or { fixed: [sessions:own, agents:read] }
15
- * allowedScopes: [agents:read, sessions:own, sandboxes:write, studio]
16
- * agents: [support] # optional allowlist; absent means all
17
- * sandboxes: ["{org_id}/*"] # optional grants; absent reaches none
18
- * maxLifetime: 15m
15
+ * # Optional, with their defaults:
16
+ * subject: "{sub}" # scalar claims only
17
+ * scopes: { claim: scope } # or { fixed: [sessions:own, agents:read] }
18
+ * allowedScopes: [agents:read, sessions:own, sandboxes:write] # add studio to grant it
19
+ * agents: [support] # absent reaches every agent
20
+ * sandboxes: ["{org_id}/*"] # absent reaches no sandbox
19
21
  * ```
20
22
  */
21
23
  import { createPublicKey } from "node:crypto";
22
24
  import { parse as parseYaml } from "yaml";
23
25
  import { z } from "zod";
24
- import { ISSUER_SCOPES, isSandboxGrant, TOKEN_SANDBOX_GRANTS_MAX, } from "@nylorun/core/contracts";
25
- import { parseSandboxDuration } from "@nylorun/core/define";
26
+ import { ISSUER_SCOPES, isSandboxGrant, TOKEN_SANDBOX_GRANTS_MAX, TOKEN_SCOPES, } from "@nylorun/core/contracts";
26
27
  /** The JWT algorithms an issuer may sign with. */
27
28
  export const ISSUER_ALGORITHMS = ["RS256", "ES256", "EdDSA"];
28
- /** The longest `maxLifetime` an issuer may set. */
29
- const MAX_LIFETIME_SECONDS = 24 * 60 * 60;
29
+ /** An entry's defaults: the `sub` claim, the RFC 9068 `scope` claim, every token scope but `studio`. */
30
+ const DEFAULT_SUBJECT = "{sub}";
31
+ const DEFAULT_SCOPES = { claim: "scope" };
32
+ const DEFAULT_ALLOWED_SCOPES = TOKEN_SCOPES;
30
33
  /** At most this many static `keys` per issuer. */
31
34
  const MAX_STATIC_KEYS = 16;
32
35
  const ISSUER_NAME = /^[a-z][a-z0-9-]{0,31}$/;
@@ -105,12 +108,16 @@ const IssuerEntrySchema = z
105
108
  }, "must be an http(s) URL without credentials")
106
109
  .optional(),
107
110
  keys: z.array(z.string().min(1)).min(1).max(MAX_STATIC_KEYS).optional(),
108
- subject: template("subject").refine((value) => claimReferences(value).length > 0, "must reference a claim, as u:{sub}: without one every token is the same person"),
109
- scopes: z.union([
111
+ subject: template("subject")
112
+ .refine((value) => claimReferences(value).length > 0, "must reference a claim, as {sub}: without one every token is the same person")
113
+ .default(DEFAULT_SUBJECT),
114
+ scopes: z
115
+ .union([
110
116
  z.object({ claim: z.string().min(1) }).strict(),
111
117
  z.object({ fixed: z.array(IssuerScopeSchema) }).strict(),
112
- ], { error: "must be { claim: <name> } or { fixed: [<scope>, ...] }" }),
113
- allowedScopes: z.array(IssuerScopeSchema).min(1),
118
+ ], { error: "must be { claim: <name> } or { fixed: [<scope>, ...] }" })
119
+ .default(DEFAULT_SCOPES),
120
+ allowedScopes: z.array(IssuerScopeSchema).min(1).default([...DEFAULT_ALLOWED_SCOPES]),
114
121
  agents: z.array(z.string().min(1)).optional(),
115
122
  sandboxes: z
116
123
  .array(template("a sandbox grant").refine(
@@ -118,17 +125,17 @@ const IssuerEntrySchema = z
118
125
  (value) => isSandboxGrant(renderTemplate(value, () => "x")), "must render to a sandbox id or a prefix ending in /*, as {org_id}/*"))
119
126
  .max(TOKEN_SANDBOX_GRANTS_MAX)
120
127
  .optional(),
121
- maxLifetime: z
122
- .string()
123
- .refine((value) => {
124
- const ms = parseSandboxDuration(value);
125
- return ms !== undefined && ms >= 1000 && ms <= MAX_LIFETIME_SECONDS * 1000;
126
- }, "must be a duration from 1s to 24h, as 15m"),
127
- })
128
- .strict();
129
- const IdentityFileSchema = z
130
- .object({ issuers: z.array(z.unknown()) })
131
- .strict();
128
+ });
129
+ const IdentityFileSchema = z.object({ issuers: z.array(z.unknown()) });
130
+ /** The keys an entry and the file define; any other is ignored with a warning. */
131
+ const ENTRY_KEYS = new Set(Object.keys(IssuerEntrySchema.shape));
132
+ const FILE_KEYS = new Set(Object.keys(IdentityFileSchema.shape));
133
+ /** The keys of `value`, an object, that `known` does not define. */
134
+ function unknownKeys(value, known) {
135
+ return value && typeof value === "object" && !Array.isArray(value)
136
+ ? Object.keys(value).filter((key) => !known.has(key))
137
+ : [];
138
+ }
132
139
  function issueText(issues) {
133
140
  return issues
134
141
  .map((issue) => {
@@ -141,7 +148,11 @@ function issueText(issues) {
141
148
  * The issuers of an identity file's text. Throws `IdentityFileError` naming the file, the issuer
142
149
  * and the field when the file is malformed.
143
150
  */
144
- export function parseIdentityFile(text, source = "identity file") {
151
+ export function parseIdentityFile(text, source = "identity file", options = {}) {
152
+ const ignored = (where, keys) => {
153
+ if (keys.length > 0)
154
+ options.warn?.(`${source}: ${where}: ignored ${keys.map((key) => `\`${key}\``).join(", ")}, which the identity file does not define`);
155
+ };
145
156
  const fail = (message) => {
146
157
  throw new IdentityFileError(`${source}: ${message}`);
147
158
  };
@@ -155,6 +166,7 @@ export function parseIdentityFile(text, source = "identity file") {
155
166
  const file = IdentityFileSchema.safeParse(raw ?? {});
156
167
  if (!file.success)
157
168
  return fail(issueText(file.error.issues));
169
+ ignored("the file", unknownKeys(raw, FILE_KEYS));
158
170
  const issuers = [];
159
171
  file.data.issuers.forEach((entry, index) => {
160
172
  const named = entry && typeof entry === "object" && typeof entry.name === "string"
@@ -164,6 +176,7 @@ export function parseIdentityFile(text, source = "identity file") {
164
176
  const parsed = IssuerEntrySchema.safeParse(entry);
165
177
  if (!parsed.success)
166
178
  return fail(`${where}: ${issueText(parsed.error.issues)}`);
179
+ ignored(where, unknownKeys(entry, ENTRY_KEYS));
167
180
  const value = parsed.data;
168
181
  if ((value.jwks === undefined) === (value.keys === undefined))
169
182
  return fail(`${where}: jwks or keys: give exactly one of them`);
@@ -195,7 +208,6 @@ export function parseIdentityFile(text, source = "identity file") {
195
208
  allowedScopes: [...new Set(value.allowedScopes)],
196
209
  ...(value.agents ? { agents: [...new Set(value.agents)] } : {}),
197
210
  ...(value.sandboxes ? { sandboxes: value.sandboxes } : {}),
198
- maxLifetimeSeconds: Math.floor(parseSandboxDuration(value.maxLifetime) / 1000),
199
211
  });
200
212
  });
201
213
  return issuers;
@@ -26,7 +26,7 @@ export type IssuerVerdict = {
26
26
  /** One configured issuer, with its keys. */
27
27
  export interface TrustedIssuer {
28
28
  readonly config: TrustedIssuerConfig;
29
- /** Checks `raw`'s header and signature, `iss`, `aud`, `exp`, `iat` and its lifetime. */
29
+ /** Checks `raw`'s header and signature, `iss`, `aud`, `exp` and `iat`. */
30
30
  verify(raw: string): Promise<IssuerVerdict>;
31
31
  }
32
32
  /** The Host's trusted issuers (`TenantConfig.issuers`), built once from the identity file. */
@@ -43,6 +43,7 @@ export type IssuerTokenScope = Extract<AuthScope, {
43
43
  /**
44
44
  * Verifies a bearer that `issuer` claims (`TrustedIssuers.claimed`). Returns the request's
45
45
  * scope; answers `401 token_expired` for an expired token, `401 issuer_unavailable` for a key
46
- * the Runtime cannot fetch now, and the opaque 404 for everything else.
46
+ * the Runtime cannot fetch now, and `401 credential_invalid` for everything else. Each carries
47
+ * a `Bearer` challenge naming `resourceMetadata`, the route's protected resource metadata.
47
48
  */
48
- export declare function verifyIssuerToken(ctx: TenantContext, issuer: TrustedIssuer, raw: string): Promise<IssuerTokenScope>;
49
+ export declare function verifyIssuerToken(ctx: TenantContext, issuer: TrustedIssuer, raw: string, resourceMetadata?: string): Promise<IssuerTokenScope>;
@@ -11,19 +11,21 @@
11
11
  * a `kid` it has not seen, and refreshed in the background once the cache is 10 minutes old.
12
12
  * When the JWKS cannot be fetched, cached keys keep working and a new `kid` is
13
13
  * `401 issuer_unavailable`;
14
- * - `iss` and `aud` as configured, `exp` and `iat` present, `exp - iat` at most `maxLifetime`,
15
- * with the 30 s clock tolerance of every Runtime token;
14
+ * - `iss` and `aud` as configured, and `exp` present, with the 30 s clock tolerance of every
15
+ * Runtime token. How long a token lives is the issuer's to decide (protocol 9 dropped
16
+ * `maxLifetime`); an `iat` in the future is still refused;
16
17
  * - a subject the template renders from scalar claims, valid and not reserved.
17
18
  *
18
19
  * The result is the `token` AuthScope (F9-D12) with `issuer`: only its expiry ends it. An
19
- * expired token is `401 token_expired`; every other refusal is the opaque 404, its reason
20
- * logged.
20
+ * expired token is `401 token_expired`; every other refusal is `401 credential_invalid`
21
+ * (`resource-server.ts`), its reason logged.
21
22
  */
22
23
  import { createHash, createPublicKey } from "node:crypto";
23
24
  import { decodeJwt, decodeProtectedHeader, errors, jwtVerify } from "jose";
24
25
  import { isSubject } from "@nylorun/core/contracts";
25
- import { failOpaque, HttpError } from "./http.js";
26
+ import { HttpError } from "./http.js";
26
27
  import { CLOCK_TOLERANCE_SECONDS, looksLikeToken } from "./jwt.js";
28
+ import { bearerChallenge, failCredential } from "./resource-server.js";
27
29
  import { ISSUER_ALGORITHMS, issuerKey, renderTemplate, } from "./identity-file.js";
28
30
  /** The byte cap of an issuer's token; Nylorun's own token families keep 4 KiB. */
29
31
  export const MAX_ISSUER_TOKEN_BYTES = 16 * 1024;
@@ -185,14 +187,12 @@ function trustedIssuer(config, options) {
185
187
  issuer: config.issuer,
186
188
  audience: config.audience,
187
189
  clockTolerance: CLOCK_TOLERANCE_SECONDS,
188
- requiredClaims: ["iat", "exp"],
190
+ requiredClaims: ["exp"],
189
191
  currentDate: new Date(options.now()),
190
192
  });
191
193
  const now = Math.floor(options.now() / 1000);
192
- if (payload.iat > now + CLOCK_TOLERANCE_SECONDS)
194
+ if (payload.iat !== undefined && payload.iat > now + CLOCK_TOLERANCE_SECONDS)
193
195
  return refuse("invalid", "issuer_token_future");
194
- if (payload.exp - payload.iat > config.maxLifetimeSeconds)
195
- return refuse("invalid", "issuer_token_lifetime");
196
196
  return { ok: true, payload, ...(kid !== undefined ? { kid } : {}) };
197
197
  }
198
198
  catch (error) {
@@ -260,25 +260,31 @@ function scopesOf(config, payload) {
260
260
  /**
261
261
  * Verifies a bearer that `issuer` claims (`TrustedIssuers.claimed`). Returns the request's
262
262
  * scope; answers `401 token_expired` for an expired token, `401 issuer_unavailable` for a key
263
- * the Runtime cannot fetch now, and the opaque 404 for everything else.
263
+ * the Runtime cannot fetch now, and `401 credential_invalid` for everything else. Each carries
264
+ * a `Bearer` challenge naming `resourceMetadata`, the route's protected resource metadata.
264
265
  */
265
- export async function verifyIssuerToken(ctx, issuer, raw) {
266
+ export async function verifyIssuerToken(ctx, issuer, raw, resourceMetadata) {
266
267
  const { config } = issuer;
267
268
  const rejected = (reason) => {
268
269
  ctx.config.logger.warn("credential rejected", { reason, issuer: config.name });
269
- return failOpaque();
270
+ return failCredential("invalid", resourceMetadata);
270
271
  };
271
272
  const verdict = await issuer.verify(raw);
272
273
  if (!verdict.ok) {
273
274
  if (verdict.refused === "expired") {
274
275
  ctx.config.logger.warn("issuer token refused", { reason: verdict.reason, issuer: config.name });
275
- throw new HttpError(401, "The token has expired", { code: "token_expired" }, { "www-authenticate": 'Bearer error="invalid_token"' });
276
+ throw new HttpError(401, "The token has expired", { code: "token_expired" }, {
277
+ "www-authenticate": bearerChallenge({
278
+ error: "invalid_token",
279
+ description: "The access token expired",
280
+ resourceMetadata,
281
+ }),
282
+ });
276
283
  }
277
284
  if (verdict.refused === "unavailable") {
278
285
  ctx.config.logger.warn("issuer token refused", { reason: verdict.reason, issuer: config.name });
279
- throw new HttpError(401, `The keys of issuer ${config.name} cannot be fetched now; try again later`, {
280
- code: "issuer_unavailable",
281
- });
286
+ // The token may be fine: no error code, so a client retries rather than signs in again.
287
+ throw new HttpError(401, `The keys of issuer ${config.name} cannot be fetched now; try again later`, { code: "issuer_unavailable" }, { "www-authenticate": bearerChallenge({ resourceMetadata }) });
282
288
  }
283
289
  return rejected(verdict.reason);
284
290
  }
@@ -15,7 +15,7 @@ export interface ResetTenantContext {
15
15
  /**
16
16
  * Reset Tenant durable state for `scope` (A17).
17
17
  * Caller must have already drained (`activeWork`). Deletes store state in one
18
- * transaction (`Tx.reset`), then does the filesystem rename+delete for sandboxes /
19
- * log as needed. The host vault (model credentials), principals and settings stay.
18
+ * transaction (`Tx.reset`), which also signals a sessions reset to every process on the
19
+ * control bus, then does the filesystem rename+delete for sandboxes / log as needed. The host vault (model credentials), principals and settings stay.
20
20
  */
21
21
  export declare function resetTenant(ctx: ResetTenantContext, scope: ResetScope): Promise<void>;
@@ -33,8 +33,8 @@ function replaceDirectory(dir) {
33
33
  /**
34
34
  * Reset Tenant durable state for `scope` (A17).
35
35
  * Caller must have already drained (`activeWork`). Deletes store state in one
36
- * transaction (`Tx.reset`), then does the filesystem rename+delete for sandboxes /
37
- * log as needed. The host vault (model credentials), principals and settings stay.
36
+ * transaction (`Tx.reset`), which also signals a sessions reset to every process on the
37
+ * control bus, then does the filesystem rename+delete for sandboxes / log as needed. The host vault (model credentials), principals and settings stay.
38
38
  */
39
39
  export async function resetTenant(ctx, scope) {
40
40
  const clearSessions = scope === "sessions" || scope === "all";
@@ -53,6 +53,12 @@ export async function resetTenant(ctx, scope) {
53
53
  // Folders' content-addressed files: those no remaining folder names (F8.2).
54
54
  const content = clearSessions ? await t.artifactContentShas(scoped) : [];
55
55
  await t.reset(scope);
56
+ // Every process moves its session streams to the new basin generation (control.ts).
57
+ if (clearSessions)
58
+ await t.signal({
59
+ type: "sessions.reset",
60
+ generation: (await t.basinGenerations()).current,
61
+ });
56
62
  const kept = await t.referencedArtifactContent(content);
57
63
  for (const sha of content)
58
64
  if (!kept.has(sha))