@nylorun/runtime 0.19.0-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.
- package/CHANGELOG.md +32 -0
- package/README.md +12 -7
- package/dist/adapters/streams/s2.js +5 -11
- package/dist/api/components.d.ts +171 -169
- package/dist/api/http/define.d.ts +3 -2
- package/dist/api/http/define.js +10 -11
- package/dist/api/openapi.js +45 -22
- package/dist/execution/types.d.ts +1 -1
- package/dist/host/app.d.ts +3 -0
- package/dist/host/app.js +33 -4
- package/dist/host/create-host.d.ts +6 -0
- package/dist/host/create-host.js +1 -0
- package/dist/host/main.js +11 -4
- package/dist/host/stack-config.js +1 -1
- package/dist/infra/index.d.ts +2 -1
- package/dist/infra/index.js +0 -1
- package/dist/infra/readiness.d.ts +6 -6
- package/dist/infra/readiness.js +2 -5
- package/dist/management-openapi.json +430 -107
- package/dist/openapi.json +761 -630
- package/dist/store/postgres/connect.d.ts +10 -0
- package/dist/store/postgres/connect.js +30 -3
- package/dist/store/postgres/control.d.ts +14 -0
- package/dist/store/postgres/control.js +187 -0
- package/dist/store/postgres/drizzle/0015_control_signals.sql +11 -0
- package/dist/store/postgres/drizzle/meta/_journal.json +7 -0
- package/dist/store/postgres/schema.d.ts +120 -0
- package/dist/store/postgres/schema.js +20 -0
- package/dist/store/postgres/store.js +15 -0
- package/dist/store/types.d.ts +40 -0
- package/dist/streams/types.d.ts +6 -29
- package/dist/streams/types.js +5 -4
- package/dist/tenant/auth.d.ts +16 -6
- package/dist/tenant/auth.js +41 -17
- package/dist/tenant/commands.js +10 -6
- package/dist/tenant/context.d.ts +4 -0
- package/dist/tenant/control.d.ts +26 -0
- package/dist/tenant/control.js +35 -0
- package/dist/tenant/ephemeral.js +1 -0
- package/dist/tenant/http.d.ts +4 -2
- package/dist/tenant/http.js +4 -2
- package/dist/tenant/identity-file.d.ts +16 -12
- package/dist/tenant/identity-file.js +41 -29
- package/dist/tenant/issuers.d.ts +4 -3
- package/dist/tenant/issuers.js +22 -16
- package/dist/tenant/reset.d.ts +2 -2
- package/dist/tenant/reset.js +8 -2
- package/dist/tenant/resource-server.d.ts +56 -0
- package/dist/tenant/resource-server.js +62 -0
- package/dist/tenant/runtime.d.ts +1 -1
- package/dist/tenant/runtime.js +7 -1
- package/dist/tenant/scheduler.d.ts +1 -1
- package/dist/tenant/scheduler.js +1 -1
- package/dist/tenant/streams.d.ts +16 -22
- package/dist/tenant/streams.js +7 -87
- package/dist/tenant/sweep.js +9 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
- package/dist/streams/control.d.ts +0 -13
- package/dist/streams/control.js +0 -25
package/dist/tenant/auth.js
CHANGED
|
@@ -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
|
|
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
|
|
23
|
-
const token =
|
|
24
|
-
|
|
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
|
|
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
|
|
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
|
|
68
|
+
return failCredential("invalid", metadata);
|
|
58
69
|
}
|
|
59
|
-
// Read only after authentication, so an unknown caller
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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) {
|
package/dist/tenant/commands.js
CHANGED
|
@@ -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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
87
|
-
t.
|
|
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;
|
package/dist/tenant/context.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/tenant/ephemeral.js
CHANGED
|
@@ -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();
|
package/dist/tenant/http.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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";
|
package/dist/tenant/http.js
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
13
|
+
* audience: https://agents.acme.dev
|
|
12
14
|
* jwks: https://sso.acme.dev/realms/eng/protocol/openid-connect/certs # or keys: [<PEM>]
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* agents:
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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 `
|
|
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:
|
|
13
|
+
* audience: https://agents.acme.dev
|
|
12
14
|
* jwks: https://sso.acme.dev/realms/eng/protocol/openid-connect/certs # or keys: [<PEM>]
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* agents:
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
/**
|
|
29
|
-
const
|
|
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")
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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;
|
package/dist/tenant/issuers.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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>;
|
package/dist/tenant/issuers.js
CHANGED
|
@@ -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,
|
|
15
|
-
*
|
|
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
|
|
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 {
|
|
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: ["
|
|
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
|
|
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
|
|
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" }, {
|
|
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
|
-
|
|
280
|
-
|
|
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
|
}
|
package/dist/tenant/reset.d.ts
CHANGED
|
@@ -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`),
|
|
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>;
|
package/dist/tenant/reset.js
CHANGED
|
@@ -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`),
|
|
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))
|