@nylorun/runtime 0.16.0-beta → 0.19.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 +207 -0
- package/README.md +105 -63
- package/dist/adapters/execution/restate.d.ts +1 -3
- package/dist/adapters/execution/restate.js +4 -31
- package/dist/adapters/replication/pgoutput.js +1 -1
- package/dist/adapters/sandbox/local.js +1 -1
- package/dist/adapters/sandbox/virtual.js +1 -1
- package/dist/api/ag-ui/endpoint.js +1 -2
- package/dist/api/ag-ui/routes.d.ts +1 -1
- package/dist/api/ag-ui/routes.js +11 -13
- package/dist/api/ag-ui/translate.d.ts +1 -3
- package/dist/api/ag-ui/translate.js +2 -17
- package/dist/api/components.d.ts +4783 -2588
- package/dist/api/components.js +19 -21
- package/dist/api/gate/routes.d.ts +7 -8
- package/dist/api/gate/routes.js +58 -52
- package/dist/api/http/app.d.ts +6 -0
- package/dist/api/http/app.js +20 -14
- package/dist/api/http/define.d.ts +22 -13
- package/dist/api/http/define.js +43 -39
- package/dist/api/http/routes/access.d.ts +3 -19
- package/dist/api/http/routes/access.js +25 -184
- package/dist/api/http/routes/artifacts.js +3 -7
- package/dist/api/http/routes/files.d.ts +8 -0
- package/dist/api/http/routes/files.js +77 -0
- package/dist/api/http/routes/keys.d.ts +10 -0
- package/dist/api/http/routes/keys.js +64 -0
- package/dist/api/http/routes/me.d.ts +13 -0
- package/dist/api/http/routes/me.js +48 -0
- package/dist/api/http/routes/reads.d.ts +66 -0
- package/dist/api/http/routes/reads.js +127 -0
- package/dist/api/http/routes/sandboxes.d.ts +2 -2
- package/dist/api/http/routes/sandboxes.js +8 -7
- package/dist/api/http/routes/sessions.js +31 -20
- package/dist/api/http/routes/tenant.js +15 -18
- package/dist/api/http/routes/vaults.d.ts +13 -3
- package/dist/api/http/routes/vaults.js +136 -64
- package/dist/api/openapi.d.ts +10 -11
- package/dist/api/openapi.js +292 -68
- package/dist/artifacts/links.js +6 -6
- package/dist/artifacts/service.js +1 -1
- package/dist/core/flow-host.d.ts +44 -23
- package/dist/core/flow-host.js +88 -106
- package/dist/core/provider.d.ts +4 -1
- package/dist/core/provider.js +6 -1
- package/dist/core/sandbox-routes.d.ts +0 -9
- package/dist/core/sandbox-routes.js +2 -21
- package/dist/execution/memory.d.ts +4 -5
- package/dist/execution/memory.js +11 -25
- package/dist/execution/types.d.ts +3 -33
- package/dist/execution/types.js +2 -12
- package/dist/gates/http-tool.d.ts +86 -0
- package/dist/gates/http-tool.js +197 -0
- package/dist/gates/mcp-handler.d.ts +3 -0
- package/dist/gates/mcp-handler.js +6 -4
- package/dist/gates/meter.d.ts +2 -2
- package/dist/gates/meter.js +6 -0
- package/dist/gates/tenant-vaults.d.ts +19 -14
- package/dist/gates/tenant-vaults.js +17 -12
- package/dist/gates/tool-calls.d.ts +7 -15
- package/dist/gates/tool-calls.js +4 -4
- package/dist/gates/tool-client.d.ts +1 -1
- package/dist/gates/tool-client.js +23 -47
- package/dist/gates/tool-contract.d.ts +27 -39
- package/dist/gates/tool-contract.js +30 -21
- package/dist/gates/tool-gate.d.ts +25 -24
- package/dist/gates/tool-gate.js +11 -7
- package/dist/harness/calls.d.ts +10 -4
- package/dist/harness/calls.js +15 -6
- package/dist/harness/executors.d.ts +1 -1
- package/dist/harness/executors.js +17 -12
- package/dist/harness/main.d.ts +2 -2
- package/dist/harness/main.js +5 -13
- package/dist/harness/service.d.ts +4 -5
- package/dist/harness/service.js +9 -2
- package/dist/harness-api/in-process.js +2 -2
- package/dist/harness-api/record.d.ts +6 -3
- package/dist/harness-api/record.js +65 -72
- package/dist/harness-api/server.d.ts +1 -9
- package/dist/harness-api/server.js +19 -17
- package/dist/harness-api/start.js +1 -5
- package/dist/harness-api/workspace.js +2 -3
- package/dist/harness-api/ws-server.js +1 -1
- package/dist/host/app.d.ts +2 -10
- package/dist/host/app.js +24 -44
- package/dist/host/config-for.d.ts +5 -5
- package/dist/host/config-for.js +2 -3
- package/dist/host/config.d.ts +0 -11
- package/dist/host/create-host.d.ts +3 -40
- package/dist/host/create-host.js +18 -91
- package/dist/host/environment.d.ts +0 -3
- package/dist/host/environment.js +0 -9
- package/dist/host/gates.d.ts +4 -1
- package/dist/host/gates.js +9 -3
- package/dist/host/http.d.ts +1 -2
- package/dist/host/http.js +1 -7
- package/dist/host/index.d.ts +2 -2
- package/dist/host/index.js +2 -2
- package/dist/host/main.js +55 -40
- package/dist/host/operate.d.ts +29 -0
- package/dist/host/operate.js +223 -0
- package/dist/host/stack-config.d.ts +23 -20
- package/dist/host/stack-config.js +28 -44
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/infra/workers.d.ts +1 -1
- package/dist/keys/client.js +9 -1
- package/dist/keys/keys.d.ts +12 -2
- package/dist/keys/keys.js +10 -2
- package/dist/management-openapi.json +6491 -0
- package/dist/mcp/connect.d.ts +8 -4
- package/dist/mcp/connect.js +8 -27
- package/dist/mcp/pool.d.ts +6 -9
- package/dist/mcp/pool.js +8 -18
- package/dist/mcp/snapshot.d.ts +8 -2
- package/dist/mcp/snapshot.js +29 -2
- package/dist/media.d.ts +1 -1
- package/dist/media.js +1 -1
- package/dist/model/pi-model.js +3 -0
- package/dist/openapi.json +6228 -15615
- package/dist/reads/access.d.ts +4 -0
- package/dist/reads/access.js +12 -0
- package/dist/reads/cursor.d.ts +11 -0
- package/dist/reads/cursor.js +80 -0
- package/dist/reads/types.d.ts +25 -0
- package/dist/reads/types.js +1 -0
- package/dist/sandbox/manager.d.ts +16 -0
- package/dist/sandbox/manager.js +40 -0
- package/dist/sandbox/session-sandbox.js +13 -3
- package/dist/sandbox/skills.d.ts +13 -0
- package/dist/sandbox/skills.js +46 -0
- package/dist/sandbox/types.d.ts +2 -2
- package/dist/store/ownership.d.ts +9 -0
- package/dist/store/ownership.js +13 -0
- package/dist/store/postgres/connect.d.ts +2 -0
- package/dist/store/postgres/connect.js +29 -0
- package/dist/store/postgres/drizzle/0008_installation_vaults.sql +2 -0
- package/dist/store/postgres/drizzle/0009_oauth_pending.sql +19 -0
- package/dist/store/postgres/drizzle/0010_oss_auth_removals.sql +3 -0
- package/dist/store/postgres/drizzle/0011_key_roles.sql +3 -0
- package/dist/store/postgres/drizzle/0012_session_reads.sql +8 -0
- package/dist/store/postgres/drizzle/0013_definition_files.sql +16 -0
- package/dist/store/postgres/drizzle/0014_action_endpoints_removed.sql +2 -0
- package/dist/store/postgres/drizzle/meta/_journal.json +49 -0
- package/dist/store/postgres/reads.d.ts +3 -0
- package/dist/store/postgres/reads.js +272 -0
- package/dist/store/postgres/schema.d.ts +423 -594
- package/dist/store/postgres/schema.js +84 -101
- package/dist/store/postgres/store.js +96 -248
- package/dist/store/postgres/tenant.d.ts +34 -2
- package/dist/store/postgres/tenant.js +16 -32
- package/dist/store/types.d.ts +44 -119
- package/dist/store/types.js +0 -1
- package/dist/streams/control.d.ts +0 -7
- package/dist/streams/control.js +0 -13
- package/dist/streams/types.d.ts +2 -20
- package/dist/streams/types.js +1 -1
- package/dist/tenant/advance.d.ts +3 -3
- package/dist/tenant/advance.js +10 -7
- package/dist/tenant/auth.d.ts +24 -15
- package/dist/tenant/auth.js +44 -55
- package/dist/tenant/cause.d.ts +3 -3
- package/dist/tenant/cause.js +1 -1
- package/dist/tenant/commands.d.ts +2 -18
- package/dist/tenant/commands.js +38 -179
- package/dist/tenant/context.d.ts +33 -45
- package/dist/tenant/definition-files.d.ts +24 -0
- package/dist/tenant/definition-files.js +103 -0
- package/dist/tenant/effects.d.ts +20 -18
- package/dist/tenant/effects.js +59 -133
- package/dist/tenant/ephemeral.d.ts +21 -8
- package/dist/tenant/ephemeral.js +11 -12
- package/dist/tenant/host-token.js +5 -5
- package/dist/tenant/identity-file.d.ts +75 -0
- package/dist/tenant/identity-file.js +202 -0
- package/dist/tenant/issuers.d.ts +48 -0
- package/dist/tenant/issuers.js +311 -0
- package/dist/tenant/jwt.js +3 -3
- package/dist/tenant/module.d.ts +1 -4
- package/dist/tenant/module.js +3 -12
- package/dist/tenant/operator-keys.d.ts +44 -0
- package/dist/tenant/operator-keys.js +87 -0
- package/dist/tenant/outbound.d.ts +40 -1
- package/dist/tenant/outbound.js +167 -18
- package/dist/tenant/paths.d.ts +1 -1
- package/dist/tenant/paths.js +1 -3
- package/dist/tenant/principals.d.ts +13 -13
- package/dist/tenant/principals.js +35 -36
- package/dist/tenant/run-token.js +6 -6
- package/dist/tenant/runtime.d.ts +5 -3
- package/dist/tenant/runtime.js +29 -15
- package/dist/tenant/sandboxes.d.ts +9 -6
- package/dist/tenant/sandboxes.js +16 -5
- package/dist/tenant/scheduler.d.ts +1 -5
- package/dist/tenant/scheduler.js +2 -5
- package/dist/tenant/session-streams.d.ts +8 -11
- package/dist/tenant/session-streams.js +48 -32
- package/dist/tenant/session.d.ts +8 -41
- package/dist/tenant/session.js +0 -37
- package/dist/tenant/sessions.d.ts +2 -2
- package/dist/tenant/sessions.js +17 -9
- package/dist/tenant/signing-keys.d.ts +3 -2
- package/dist/tenant/signing-keys.js +5 -5
- package/dist/tenant/skill-tool.d.ts +20 -0
- package/dist/tenant/skill-tool.js +72 -0
- package/dist/tenant/slim.d.ts +1 -1
- package/dist/tenant/slim.js +1 -1
- package/dist/tenant/status.js +7 -12
- package/dist/tenant/store-pg.d.ts +15 -0
- package/dist/tenant/store-pg.js +7 -3
- package/dist/tenant/streams.d.ts +3 -11
- package/dist/tenant/streams.js +2 -29
- package/dist/tenant/sweep.js +0 -5
- package/dist/tenant/transcript.d.ts +2 -2
- package/dist/tenant/transcript.js +2 -2
- package/dist/tenant/types.d.ts +33 -19
- package/dist/tenant/worker.d.ts +1 -3
- package/dist/tenant/worker.js +3 -8
- package/dist/vault/crypto.d.ts +7 -0
- package/dist/vault/crypto.js +16 -0
- package/dist/vault/oauth.d.ts +55 -0
- package/dist/vault/oauth.js +186 -0
- package/dist/vault/service.d.ts +54 -7
- package/dist/vault/service.js +261 -17
- package/dist/vault/sources.d.ts +60 -0
- package/dist/vault/sources.js +196 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -4
- package/dist/admin-openapi.json +0 -604
- package/dist/api/http/routes/actions.d.ts +0 -8
- package/dist/api/http/routes/actions.js +0 -76
- package/dist/api/http/routes/endpoints.d.ts +0 -7
- package/dist/api/http/routes/endpoints.js +0 -100
- package/dist/host/admin-api.d.ts +0 -23
- package/dist/host/admin-api.js +0 -74
- package/dist/host/cors.d.ts +0 -27
- package/dist/host/cors.js +0 -64
- package/dist/mcp/stdio.d.ts +0 -17
- package/dist/mcp/stdio.js +0 -82
- package/dist/tenant/access-policy.d.ts +0 -30
- package/dist/tenant/access-policy.js +0 -52
- package/dist/tenant/actions.d.ts +0 -14
- package/dist/tenant/actions.js +0 -29
- package/dist/tenant/browser.d.ts +0 -15
- package/dist/tenant/browser.js +0 -39
- package/dist/tenant/delivery-token.d.ts +0 -41
- package/dist/tenant/delivery-token.js +0 -104
- package/dist/tenant/delivery.d.ts +0 -62
- package/dist/tenant/delivery.js +0 -481
- package/dist/tenant/endpoints.d.ts +0 -67
- package/dist/tenant/endpoints.js +0 -90
- package/dist/tenant/subject-limits.d.ts +0 -16
- package/dist/tenant/subject-limits.js +0 -45
- package/dist/tenant/tokens.d.ts +0 -14
- package/dist/tenant/tokens.js +0 -187
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The identity file (Host feature `trusted-issuers`, F9 I2): the YAML file
|
|
3
|
+
* `NYLORUN_IDENTITY_FILE` names, listing the issuers whose JWTs the Tenant API accepts as
|
|
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.
|
|
6
|
+
*
|
|
7
|
+
* ```yaml
|
|
8
|
+
* issuers:
|
|
9
|
+
* - name: keycloak
|
|
10
|
+
* issuer: https://sso.acme.dev/realms/eng
|
|
11
|
+
* audience: nylorun
|
|
12
|
+
* 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
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
import { createPublicKey } from "node:crypto";
|
|
22
|
+
import { parse as parseYaml } from "yaml";
|
|
23
|
+
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
|
+
/** The JWT algorithms an issuer may sign with. */
|
|
27
|
+
export const ISSUER_ALGORITHMS = ["RS256", "ES256", "EdDSA"];
|
|
28
|
+
/** The longest `maxLifetime` an issuer may set. */
|
|
29
|
+
const MAX_LIFETIME_SECONDS = 24 * 60 * 60;
|
|
30
|
+
/** At most this many static `keys` per issuer. */
|
|
31
|
+
const MAX_STATIC_KEYS = 16;
|
|
32
|
+
const ISSUER_NAME = /^[a-z][a-z0-9-]{0,31}$/;
|
|
33
|
+
/** A claim reference in a template: `{claim}`, a top-level claim name without braces. */
|
|
34
|
+
const CLAIM_REFERENCE = /\{([^{}]+)\}/g;
|
|
35
|
+
/** A malformed identity file: the message names the issuer and the field. */
|
|
36
|
+
export class IdentityFileError extends Error {
|
|
37
|
+
constructor(message) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "IdentityFileError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** The claim names a template references. */
|
|
43
|
+
export function claimReferences(template) {
|
|
44
|
+
return [...template.matchAll(CLAIM_REFERENCE)].map((match) => match[1]);
|
|
45
|
+
}
|
|
46
|
+
/** `template` with every reference replaced by `value(claim)`; undefined when one has none. */
|
|
47
|
+
export function renderTemplate(template, value) {
|
|
48
|
+
let missing = false;
|
|
49
|
+
const rendered = template.replace(CLAIM_REFERENCE, (_, claim) => {
|
|
50
|
+
const found = value(claim);
|
|
51
|
+
if (found === undefined)
|
|
52
|
+
missing = true;
|
|
53
|
+
return found ?? "";
|
|
54
|
+
});
|
|
55
|
+
return missing ? undefined : rendered;
|
|
56
|
+
}
|
|
57
|
+
/** True when every brace in `template` belongs to a `{claim}` reference. */
|
|
58
|
+
function bracesBalanced(template) {
|
|
59
|
+
return !/[{}]/.test(template.replace(CLAIM_REFERENCE, ""));
|
|
60
|
+
}
|
|
61
|
+
/** A PEM public key an issuer may sign with, and its algorithm; throws a message otherwise. */
|
|
62
|
+
export function issuerKey(key, kid) {
|
|
63
|
+
const details = key.asymmetricKeyDetails ?? {};
|
|
64
|
+
switch (key.asymmetricKeyType) {
|
|
65
|
+
case "rsa":
|
|
66
|
+
if ((details.modulusLength ?? 0) < 2048)
|
|
67
|
+
throw new Error("an RSA key must be at least 2048 bits");
|
|
68
|
+
return { key, alg: "RS256", ...(kid ? { kid } : {}) };
|
|
69
|
+
case "ec":
|
|
70
|
+
if (details.namedCurve !== "prime256v1")
|
|
71
|
+
throw new Error("an EC key must be on P-256 (ES256)");
|
|
72
|
+
return { key, alg: "ES256", ...(kid ? { kid } : {}) };
|
|
73
|
+
case "ed25519":
|
|
74
|
+
return { key, alg: "EdDSA", ...(kid ? { kid } : {}) };
|
|
75
|
+
default:
|
|
76
|
+
throw new Error(`a ${key.asymmetricKeyType ?? "secret"} key is not accepted: use RSA (RS256), P-256 (ES256) or Ed25519 (EdDSA)`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const template = (what) => z
|
|
80
|
+
.string()
|
|
81
|
+
.min(1)
|
|
82
|
+
.max(200)
|
|
83
|
+
.refine(bracesBalanced, `${what} may use braces only around a claim name, as {sub}`);
|
|
84
|
+
const IssuerScopeSchema = z.enum(ISSUER_SCOPES, {
|
|
85
|
+
error: (issue) => `${String(issue.input)} is not a scope an issuer may grant (one of ${ISSUER_SCOPES.join(", ")})`,
|
|
86
|
+
});
|
|
87
|
+
const IssuerEntrySchema = z
|
|
88
|
+
.object({
|
|
89
|
+
name: z.string().regex(ISSUER_NAME, "must match ^[a-z][a-z0-9-]{0,31}$"),
|
|
90
|
+
issuer: z
|
|
91
|
+
.string()
|
|
92
|
+
.min(1)
|
|
93
|
+
.refine((value) => !value.startsWith("urn:nylorun:"), "urn:nylorun: issuers are the Runtime's own"),
|
|
94
|
+
audience: z.string().min(1),
|
|
95
|
+
jwks: z
|
|
96
|
+
.string()
|
|
97
|
+
.refine((value) => {
|
|
98
|
+
try {
|
|
99
|
+
const url = new URL(value);
|
|
100
|
+
return (url.protocol === "https:" || url.protocol === "http:") && !url.username && !url.password;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}, "must be an http(s) URL without credentials")
|
|
106
|
+
.optional(),
|
|
107
|
+
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([
|
|
110
|
+
z.object({ claim: z.string().min(1) }).strict(),
|
|
111
|
+
z.object({ fixed: z.array(IssuerScopeSchema) }).strict(),
|
|
112
|
+
], { error: "must be { claim: <name> } or { fixed: [<scope>, ...] }" }),
|
|
113
|
+
allowedScopes: z.array(IssuerScopeSchema).min(1),
|
|
114
|
+
agents: z.array(z.string().min(1)).optional(),
|
|
115
|
+
sandboxes: z
|
|
116
|
+
.array(template("a sandbox grant").refine(
|
|
117
|
+
// A rendered grant must be a grant: checked here with a sample value per claim.
|
|
118
|
+
(value) => isSandboxGrant(renderTemplate(value, () => "x")), "must render to a sandbox id or a prefix ending in /*, as {org_id}/*"))
|
|
119
|
+
.max(TOKEN_SANDBOX_GRANTS_MAX)
|
|
120
|
+
.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();
|
|
132
|
+
function issueText(issues) {
|
|
133
|
+
return issues
|
|
134
|
+
.map((issue) => {
|
|
135
|
+
const path = issue.path.map((part) => (typeof part === "number" ? `[${part}]` : `.${String(part)}`)).join("").replace(/^\./, "");
|
|
136
|
+
return `${path || "(entry)"}: ${issue.message}`;
|
|
137
|
+
})
|
|
138
|
+
.join("; ");
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The issuers of an identity file's text. Throws `IdentityFileError` naming the file, the issuer
|
|
142
|
+
* and the field when the file is malformed.
|
|
143
|
+
*/
|
|
144
|
+
export function parseIdentityFile(text, source = "identity file") {
|
|
145
|
+
const fail = (message) => {
|
|
146
|
+
throw new IdentityFileError(`${source}: ${message}`);
|
|
147
|
+
};
|
|
148
|
+
let raw;
|
|
149
|
+
try {
|
|
150
|
+
raw = parseYaml(text);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
return fail(`not YAML: ${error instanceof Error ? error.message : String(error)}`);
|
|
154
|
+
}
|
|
155
|
+
const file = IdentityFileSchema.safeParse(raw ?? {});
|
|
156
|
+
if (!file.success)
|
|
157
|
+
return fail(issueText(file.error.issues));
|
|
158
|
+
const issuers = [];
|
|
159
|
+
file.data.issuers.forEach((entry, index) => {
|
|
160
|
+
const named = entry && typeof entry === "object" && typeof entry.name === "string"
|
|
161
|
+
? entry.name
|
|
162
|
+
: `#${index + 1}`;
|
|
163
|
+
const where = `issuer ${named}`;
|
|
164
|
+
const parsed = IssuerEntrySchema.safeParse(entry);
|
|
165
|
+
if (!parsed.success)
|
|
166
|
+
return fail(`${where}: ${issueText(parsed.error.issues)}`);
|
|
167
|
+
const value = parsed.data;
|
|
168
|
+
if ((value.jwks === undefined) === (value.keys === undefined))
|
|
169
|
+
return fail(`${where}: jwks or keys: give exactly one of them`);
|
|
170
|
+
if ("fixed" in value.scopes) {
|
|
171
|
+
const outside = value.scopes.fixed.filter((scope) => !value.allowedScopes.includes(scope));
|
|
172
|
+
if (outside.length > 0)
|
|
173
|
+
return fail(`${where}: scopes.fixed: ${outside.join(", ")} not in allowedScopes`);
|
|
174
|
+
}
|
|
175
|
+
const keys = value.keys?.map((pem, keyIndex) => {
|
|
176
|
+
try {
|
|
177
|
+
return issuerKey(createPublicKey(pem));
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
return fail(`${where}: keys[${keyIndex}]: ${error instanceof Error ? error.message : String(error)}`);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
if (issuers.some((other) => other.name === value.name))
|
|
184
|
+
return fail(`${where}: name: another issuer has this name`);
|
|
185
|
+
if (issuers.some((other) => other.issuer === value.issuer))
|
|
186
|
+
return fail(`${where}: issuer: another issuer has ${value.issuer}`);
|
|
187
|
+
issuers.push({
|
|
188
|
+
name: value.name,
|
|
189
|
+
issuer: value.issuer,
|
|
190
|
+
audience: value.audience,
|
|
191
|
+
...(value.jwks ? { jwks: value.jwks } : {}),
|
|
192
|
+
...(keys ? { keys } : {}),
|
|
193
|
+
subject: value.subject,
|
|
194
|
+
scopes: value.scopes,
|
|
195
|
+
allowedScopes: [...new Set(value.allowedScopes)],
|
|
196
|
+
...(value.agents ? { agents: [...new Set(value.agents)] } : {}),
|
|
197
|
+
...(value.sandboxes ? { sandboxes: value.sandboxes } : {}),
|
|
198
|
+
maxLifetimeSeconds: Math.floor(parseSandboxDuration(value.maxLifetime) / 1000),
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
return issuers;
|
|
202
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type JWTPayload } from "jose";
|
|
2
|
+
import type { AuthScope, TenantContext } from "./context.js";
|
|
3
|
+
import { type TrustedIssuerConfig } from "./identity-file.js";
|
|
4
|
+
/** The byte cap of an issuer's token; Nylorun's own token families keep 4 KiB. */
|
|
5
|
+
export declare const MAX_ISSUER_TOKEN_BYTES: number;
|
|
6
|
+
export interface TrustedIssuersOptions {
|
|
7
|
+
/** Fetches JWKS URLs. Default: the global fetch. */
|
|
8
|
+
fetch?: typeof fetch;
|
|
9
|
+
/** The least time between two JWKS fetches of one issuer for an unknown `kid`. Default 60 s. */
|
|
10
|
+
refetchMs?: number;
|
|
11
|
+
/** How old cached JWKS keys get before a background refresh. Default 10 minutes. */
|
|
12
|
+
maxAgeMs?: number;
|
|
13
|
+
/** Milliseconds since the epoch. Default `Date.now`. */
|
|
14
|
+
now?: () => number;
|
|
15
|
+
}
|
|
16
|
+
/** What verifying an issuer's token found. */
|
|
17
|
+
export type IssuerVerdict = {
|
|
18
|
+
readonly ok: true;
|
|
19
|
+
readonly payload: JWTPayload;
|
|
20
|
+
readonly kid?: string;
|
|
21
|
+
} | {
|
|
22
|
+
readonly ok: false;
|
|
23
|
+
readonly refused: "invalid" | "expired" | "unavailable";
|
|
24
|
+
readonly reason: string;
|
|
25
|
+
};
|
|
26
|
+
/** One configured issuer, with its keys. */
|
|
27
|
+
export interface TrustedIssuer {
|
|
28
|
+
readonly config: TrustedIssuerConfig;
|
|
29
|
+
/** Checks `raw`'s header and signature, `iss`, `aud`, `exp`, `iat` and its lifetime. */
|
|
30
|
+
verify(raw: string): Promise<IssuerVerdict>;
|
|
31
|
+
}
|
|
32
|
+
/** The Host's trusted issuers (`TenantConfig.issuers`), built once from the identity file. */
|
|
33
|
+
export interface TrustedIssuers {
|
|
34
|
+
readonly issuers: readonly TrustedIssuer[];
|
|
35
|
+
/** The issuer a JWT-shaped bearer claims by its unverified `iss`, if it is one of these. */
|
|
36
|
+
claimed(raw: string): TrustedIssuer | undefined;
|
|
37
|
+
}
|
|
38
|
+
/** The trusted issuers of `configs` (`parseIdentityFile`). No key is fetched until needed. */
|
|
39
|
+
export declare function createTrustedIssuers(configs: readonly TrustedIssuerConfig[], options?: TrustedIssuersOptions): TrustedIssuers;
|
|
40
|
+
export type IssuerTokenScope = Extract<AuthScope, {
|
|
41
|
+
kind: "token";
|
|
42
|
+
}>;
|
|
43
|
+
/**
|
|
44
|
+
* Verifies a bearer that `issuer` claims (`TrustedIssuers.claimed`). Returns the request's
|
|
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.
|
|
47
|
+
*/
|
|
48
|
+
export declare function verifyIssuerToken(ctx: TenantContext, issuer: TrustedIssuer, raw: string): Promise<IssuerTokenScope>;
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trusted issuers (Host feature `trusted-issuers`, F9 I2): JWTs from the operator's own
|
|
3
|
+
* identity provider, declared in the identity file (`identity-file.ts`), accepted as bearers.
|
|
4
|
+
* A token is one of theirs when its unverified `iss` names a configured issuer; it is then
|
|
5
|
+
* verified like this, and nothing else is tried:
|
|
6
|
+
*
|
|
7
|
+
* - at most 16 KiB (identity providers' tokens often pass Nylorun's 4 KiB), RS256, ES256 or
|
|
8
|
+
* EdDSA, and no header that could name another key (`jku`, `jwk`, `x5u`, `x5c`, `crit`);
|
|
9
|
+
* - signed by a key of the issuer: its static `keys`, or its JWKS, fetched only from the
|
|
10
|
+
* configured URL (`redirect: "error"`), cached by `kid`, refetched at most once a minute for
|
|
11
|
+
* a `kid` it has not seen, and refreshed in the background once the cache is 10 minutes old.
|
|
12
|
+
* When the JWKS cannot be fetched, cached keys keep working and a new `kid` is
|
|
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;
|
|
16
|
+
* - a subject the template renders from scalar claims, valid and not reserved.
|
|
17
|
+
*
|
|
18
|
+
* 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.
|
|
21
|
+
*/
|
|
22
|
+
import { createHash, createPublicKey } from "node:crypto";
|
|
23
|
+
import { decodeJwt, decodeProtectedHeader, errors, jwtVerify } from "jose";
|
|
24
|
+
import { isSubject } from "@nylorun/core/contracts";
|
|
25
|
+
import { failOpaque, HttpError } from "./http.js";
|
|
26
|
+
import { CLOCK_TOLERANCE_SECONDS, looksLikeToken } from "./jwt.js";
|
|
27
|
+
import { ISSUER_ALGORITHMS, issuerKey, renderTemplate, } from "./identity-file.js";
|
|
28
|
+
/** The byte cap of an issuer's token; Nylorun's own token families keep 4 KiB. */
|
|
29
|
+
export const MAX_ISSUER_TOKEN_BYTES = 16 * 1024;
|
|
30
|
+
/** Subjects the Runtime keeps for itself: never an issuer token's. */
|
|
31
|
+
const RESERVED = new Set(["host", "installation"]);
|
|
32
|
+
const FORBIDDEN_HEADERS = ["jku", "jwk", "x5u", "x5c", "crit"];
|
|
33
|
+
const JWKS_TIMEOUT_MS = 5000;
|
|
34
|
+
const JWKS_MAX_BYTES = 256 * 1024;
|
|
35
|
+
const JWKS_MAX_KEYS = 64;
|
|
36
|
+
const DEFAULT_REFETCH_MS = 60_000;
|
|
37
|
+
const DEFAULT_MAX_AGE_MS = 10 * 60_000;
|
|
38
|
+
/** A grant's claim value: one sandbox id segment, so a claim never widens the template. */
|
|
39
|
+
const GRANT_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
40
|
+
/** The issuer's public keys: static, or its JWKS's as last fetched. */
|
|
41
|
+
class IssuerKeys {
|
|
42
|
+
config;
|
|
43
|
+
options;
|
|
44
|
+
byKid = new Map();
|
|
45
|
+
unnamed = [];
|
|
46
|
+
fetchedAt = Number.NEGATIVE_INFINITY;
|
|
47
|
+
okAt = Number.NEGATIVE_INFINITY;
|
|
48
|
+
lastOk;
|
|
49
|
+
inflight;
|
|
50
|
+
constructor(config, options) {
|
|
51
|
+
this.config = config;
|
|
52
|
+
this.options = options;
|
|
53
|
+
for (const key of config.keys ?? [])
|
|
54
|
+
this.unnamed.push(key);
|
|
55
|
+
}
|
|
56
|
+
/** The keys that may have signed a token with `kid` and `alg`, or `unavailable`. */
|
|
57
|
+
async candidates(kid, alg) {
|
|
58
|
+
const usable = (key) => key.alg === alg;
|
|
59
|
+
// Static keys have no `kid`; a JWKS token with a `kid` is checked against that key alone.
|
|
60
|
+
const pick = () => {
|
|
61
|
+
if (!this.config.jwks)
|
|
62
|
+
return this.unnamed.filter(usable);
|
|
63
|
+
if (kid !== undefined) {
|
|
64
|
+
const key = this.byKid.get(kid);
|
|
65
|
+
return key && usable(key) ? [key] : [];
|
|
66
|
+
}
|
|
67
|
+
return [...this.byKid.values(), ...this.unnamed].filter(usable);
|
|
68
|
+
};
|
|
69
|
+
if (!this.config.jwks)
|
|
70
|
+
return pick();
|
|
71
|
+
const now = this.options.now();
|
|
72
|
+
let found = pick();
|
|
73
|
+
if (found.length === 0) {
|
|
74
|
+
if (this.inflight)
|
|
75
|
+
await this.inflight;
|
|
76
|
+
else if (now - this.fetchedAt >= this.options.refetchMs)
|
|
77
|
+
await this.refresh();
|
|
78
|
+
found = pick();
|
|
79
|
+
if (found.length === 0 && this.lastOk === false)
|
|
80
|
+
return "unavailable";
|
|
81
|
+
}
|
|
82
|
+
else if (now - this.okAt >= this.options.maxAgeMs && now - this.fetchedAt >= this.options.refetchMs) {
|
|
83
|
+
// Old keys keep working while the refresh runs, and when it fails.
|
|
84
|
+
void this.refresh();
|
|
85
|
+
}
|
|
86
|
+
return found;
|
|
87
|
+
}
|
|
88
|
+
/** Fetches the JWKS once, joining a fetch already running. A failure keeps the cache. */
|
|
89
|
+
refresh() {
|
|
90
|
+
this.inflight ??= this.fetchKeys().finally(() => {
|
|
91
|
+
this.inflight = undefined;
|
|
92
|
+
});
|
|
93
|
+
return this.inflight;
|
|
94
|
+
}
|
|
95
|
+
async fetchKeys() {
|
|
96
|
+
const started = this.options.now();
|
|
97
|
+
this.fetchedAt = started;
|
|
98
|
+
try {
|
|
99
|
+
const response = await this.options.fetch(this.config.jwks, {
|
|
100
|
+
redirect: "error",
|
|
101
|
+
headers: { accept: "application/jwk-set+json, application/json" },
|
|
102
|
+
signal: AbortSignal.timeout(JWKS_TIMEOUT_MS),
|
|
103
|
+
});
|
|
104
|
+
if (!response.ok)
|
|
105
|
+
throw new Error(`JWKS answered ${response.status}`);
|
|
106
|
+
const text = await response.text();
|
|
107
|
+
if (Buffer.byteLength(text) > JWKS_MAX_BYTES)
|
|
108
|
+
throw new Error("JWKS is too large");
|
|
109
|
+
const body = JSON.parse(text);
|
|
110
|
+
if (!Array.isArray(body.keys))
|
|
111
|
+
throw new Error("JWKS has no keys array");
|
|
112
|
+
const byKid = new Map();
|
|
113
|
+
const unnamed = [];
|
|
114
|
+
for (const jwk of body.keys.slice(0, JWKS_MAX_KEYS)) {
|
|
115
|
+
const key = jwkKey(jwk);
|
|
116
|
+
if (!key)
|
|
117
|
+
continue;
|
|
118
|
+
if (key.kid !== undefined)
|
|
119
|
+
byKid.set(key.kid, key);
|
|
120
|
+
else
|
|
121
|
+
unnamed.push(key);
|
|
122
|
+
}
|
|
123
|
+
this.byKid = byKid;
|
|
124
|
+
this.unnamed = unnamed;
|
|
125
|
+
this.okAt = started;
|
|
126
|
+
this.lastOk = true;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
this.lastOk = false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** A JWK an issuer signs with, or undefined for one the Runtime does not use. */
|
|
134
|
+
function jwkKey(jwk) {
|
|
135
|
+
if (!jwk || typeof jwk !== "object")
|
|
136
|
+
return undefined;
|
|
137
|
+
const entry = jwk;
|
|
138
|
+
if (entry.use !== undefined && entry.use !== "sig")
|
|
139
|
+
return undefined;
|
|
140
|
+
if (entry.d !== undefined)
|
|
141
|
+
return undefined; // a private key: never trusted as published
|
|
142
|
+
if (entry.alg !== undefined && !ISSUER_ALGORITHMS.includes(entry.alg))
|
|
143
|
+
return undefined;
|
|
144
|
+
const kid = typeof entry.kid === "string" && entry.kid.length <= 256 ? entry.kid : undefined;
|
|
145
|
+
try {
|
|
146
|
+
const key = issuerKey(createPublicKey({ key: jwk, format: "jwk" }), kid);
|
|
147
|
+
return entry.alg === undefined || entry.alg === key.alg ? key : undefined;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function trustedIssuer(config, options) {
|
|
154
|
+
const keys = new IssuerKeys(config, options);
|
|
155
|
+
return {
|
|
156
|
+
config,
|
|
157
|
+
async verify(raw) {
|
|
158
|
+
const refuse = (refused, reason) => ({
|
|
159
|
+
ok: false,
|
|
160
|
+
refused,
|
|
161
|
+
reason,
|
|
162
|
+
});
|
|
163
|
+
let header;
|
|
164
|
+
try {
|
|
165
|
+
header = decodeProtectedHeader(raw);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return refuse("invalid", "issuer_token_malformed");
|
|
169
|
+
}
|
|
170
|
+
const alg = header.alg;
|
|
171
|
+
if (!ISSUER_ALGORITHMS.includes(alg) ||
|
|
172
|
+
(header.kid !== undefined && typeof header.kid !== "string") ||
|
|
173
|
+
FORBIDDEN_HEADERS.some((name) => name in header))
|
|
174
|
+
return refuse("invalid", "issuer_token_header");
|
|
175
|
+
const kid = header.kid;
|
|
176
|
+
const candidates = await keys.candidates(kid, alg);
|
|
177
|
+
if (candidates === "unavailable")
|
|
178
|
+
return refuse("unavailable", "issuer_jwks_unavailable");
|
|
179
|
+
if (candidates.length === 0)
|
|
180
|
+
return refuse("invalid", "issuer_key_unknown");
|
|
181
|
+
for (const candidate of candidates) {
|
|
182
|
+
try {
|
|
183
|
+
const { payload } = await jwtVerify(raw, candidate.key, {
|
|
184
|
+
algorithms: [candidate.alg],
|
|
185
|
+
issuer: config.issuer,
|
|
186
|
+
audience: config.audience,
|
|
187
|
+
clockTolerance: CLOCK_TOLERANCE_SECONDS,
|
|
188
|
+
requiredClaims: ["iat", "exp"],
|
|
189
|
+
currentDate: new Date(options.now()),
|
|
190
|
+
});
|
|
191
|
+
const now = Math.floor(options.now() / 1000);
|
|
192
|
+
if (payload.iat > now + CLOCK_TOLERANCE_SECONDS)
|
|
193
|
+
return refuse("invalid", "issuer_token_future");
|
|
194
|
+
if (payload.exp - payload.iat > config.maxLifetimeSeconds)
|
|
195
|
+
return refuse("invalid", "issuer_token_lifetime");
|
|
196
|
+
return { ok: true, payload, ...(kid !== undefined ? { kid } : {}) };
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
// Signature checks come first: an expiry is only reported for a token the issuer signed.
|
|
200
|
+
if (error instanceof errors.JWSSignatureVerificationFailed)
|
|
201
|
+
continue;
|
|
202
|
+
if (error instanceof errors.JWTExpired)
|
|
203
|
+
return refuse("expired", "issuer_token_expired");
|
|
204
|
+
return refuse("invalid", "issuer_token_invalid");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return refuse("invalid", "issuer_token_signature");
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/** The trusted issuers of `configs` (`parseIdentityFile`). No key is fetched until needed. */
|
|
212
|
+
export function createTrustedIssuers(configs, options = {}) {
|
|
213
|
+
const resolved = {
|
|
214
|
+
fetch: options.fetch ?? ((input, init) => globalThis.fetch(input, init)),
|
|
215
|
+
refetchMs: options.refetchMs ?? DEFAULT_REFETCH_MS,
|
|
216
|
+
maxAgeMs: options.maxAgeMs ?? DEFAULT_MAX_AGE_MS,
|
|
217
|
+
now: options.now ?? Date.now,
|
|
218
|
+
};
|
|
219
|
+
const issuers = configs.map((config) => trustedIssuer(config, resolved));
|
|
220
|
+
const byIss = new Map(issuers.map((issuer) => [issuer.config.issuer, issuer]));
|
|
221
|
+
return {
|
|
222
|
+
issuers,
|
|
223
|
+
claimed(raw) {
|
|
224
|
+
if (byIss.size === 0 || Buffer.byteLength(raw) > MAX_ISSUER_TOKEN_BYTES || !looksLikeToken(raw))
|
|
225
|
+
return undefined;
|
|
226
|
+
try {
|
|
227
|
+
const iss = decodeJwt(raw).iss;
|
|
228
|
+
return typeof iss === "string" ? byIss.get(iss) : undefined;
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/** A claim's value as template text: strings and finite numbers only (scalar claims). */
|
|
237
|
+
function scalarClaim(payload, claim) {
|
|
238
|
+
if (!Object.hasOwn(payload, claim))
|
|
239
|
+
return undefined;
|
|
240
|
+
const value = payload[claim];
|
|
241
|
+
if (typeof value === "string" && value.length > 0)
|
|
242
|
+
return value;
|
|
243
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
244
|
+
return String(value);
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
function scopesOf(config, payload) {
|
|
248
|
+
let names = [];
|
|
249
|
+
if ("fixed" in config.scopes)
|
|
250
|
+
names = [...config.scopes.fixed];
|
|
251
|
+
else if (Object.hasOwn(payload, config.scopes.claim)) {
|
|
252
|
+
const value = payload[config.scopes.claim];
|
|
253
|
+
if (typeof value === "string")
|
|
254
|
+
names = value.split(" ");
|
|
255
|
+
else if (Array.isArray(value))
|
|
256
|
+
names = value;
|
|
257
|
+
}
|
|
258
|
+
return new Set(config.allowedScopes.filter((scope) => names.includes(scope)));
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Verifies a bearer that `issuer` claims (`TrustedIssuers.claimed`). Returns the request's
|
|
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.
|
|
264
|
+
*/
|
|
265
|
+
export async function verifyIssuerToken(ctx, issuer, raw) {
|
|
266
|
+
const { config } = issuer;
|
|
267
|
+
const rejected = (reason) => {
|
|
268
|
+
ctx.config.logger.warn("credential rejected", { reason, issuer: config.name });
|
|
269
|
+
return failOpaque();
|
|
270
|
+
};
|
|
271
|
+
const verdict = await issuer.verify(raw);
|
|
272
|
+
if (!verdict.ok) {
|
|
273
|
+
if (verdict.refused === "expired") {
|
|
274
|
+
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
|
+
}
|
|
277
|
+
if (verdict.refused === "unavailable") {
|
|
278
|
+
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
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return rejected(verdict.reason);
|
|
284
|
+
}
|
|
285
|
+
const { payload } = verdict;
|
|
286
|
+
const subject = renderTemplate(config.subject, (claim) => scalarClaim(payload, claim));
|
|
287
|
+
if (subject === undefined || !isSubject(subject) || RESERVED.has(subject))
|
|
288
|
+
return rejected("issuer_token_subject");
|
|
289
|
+
const sandboxes = config.sandboxes?.flatMap((grant) => {
|
|
290
|
+
const rendered = renderTemplate(grant, (claim) => {
|
|
291
|
+
const value = scalarClaim(payload, claim);
|
|
292
|
+
return value !== undefined && GRANT_SEGMENT.test(value) ? value : undefined;
|
|
293
|
+
});
|
|
294
|
+
// A grant whose claim is missing or not one id segment reaches nothing.
|
|
295
|
+
return rendered === undefined ? [] : [rendered];
|
|
296
|
+
});
|
|
297
|
+
const jti = typeof payload.jti === "string" && payload.jti.length > 0 && payload.jti.length <= 200
|
|
298
|
+
? payload.jti
|
|
299
|
+
: createHash("sha256").update(raw).digest("base64url");
|
|
300
|
+
return {
|
|
301
|
+
kind: "token",
|
|
302
|
+
issuer: config.name,
|
|
303
|
+
subject,
|
|
304
|
+
scopes: scopesOf(config, payload),
|
|
305
|
+
agents: config.agents ? new Set(config.agents) : "*",
|
|
306
|
+
sandboxes: [...new Set(sandboxes ?? [])],
|
|
307
|
+
expiresAt: payload.exp * 1000,
|
|
308
|
+
tokenId: jti,
|
|
309
|
+
...(verdict.kid !== undefined ? { keyId: verdict.kid } : {}),
|
|
310
|
+
};
|
|
311
|
+
}
|
package/dist/tenant/jwt.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What
|
|
3
|
-
* told apart by their `typ`, and refused before
|
|
4
|
-
* Runtime issues.
|
|
2
|
+
* What the Runtime's own tokens share (capability links, run and host tokens):
|
|
3
|
+
* the Tenant's ES256 JWTs, recognised by shape, told apart by their `typ`, and refused before
|
|
4
|
+
* any key is read when the header is not one the Runtime issues.
|
|
5
5
|
*/
|
|
6
6
|
import { decodeProtectedHeader } from "jose";
|
|
7
7
|
import { SIGNING_KEY_ID_PATTERN } from "@nylorun/core/compatibility";
|
package/dist/tenant/module.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { StreamRelayStatus } from "@nylorun/core/contracts";
|
|
2
1
|
import { TenantUnavailableError, type Logger, type TenantHandle, type TenantModule, type TenantOpener } from "./types.js";
|
|
3
2
|
export interface CreateTenantModuleOptions {
|
|
4
3
|
/** Opens the Host's Tenant (`store-pg.ts`). */
|
|
@@ -11,15 +10,13 @@ export interface CreateTenantModuleOptions {
|
|
|
11
10
|
* Tenant's id (its stream relay). A failure is logged; the Tenant stays open.
|
|
12
11
|
*/
|
|
13
12
|
onOpen?: (handle: TenantHandle) => void | Promise<void>;
|
|
14
|
-
/** This process's stream relay, for the Host aggregate (a Host with S2 runs one). */
|
|
15
|
-
relayStatus?: () => Promise<StreamRelayStatus>;
|
|
16
13
|
}
|
|
17
14
|
/**
|
|
18
15
|
* Deep Tenant module (§8): the Host's one Tenant over an injected opener (tenancy.md §5).
|
|
19
16
|
*
|
|
20
17
|
* `start` opens it once, even when requests or Worker calls ask at the same time. An open that
|
|
21
18
|
* fails for a reason in the Tenant (a `TenantOpenError`, or a timeout) is kept as the Tenant's
|
|
22
|
-
* cause: the Host is not ready,
|
|
19
|
+
* cause: the Host is not ready, `nylorun-operate status` names it, requests get the opaque 404.
|
|
23
20
|
* One that fails outside the Tenant (`TenantUnavailableError`) is retried in the background
|
|
24
21
|
* with backoff, and by the next request, which gets 503 meanwhile.
|
|
25
22
|
*/
|
package/dist/tenant/module.js
CHANGED
|
@@ -10,7 +10,7 @@ const RETRY_MAX_MS = 30_000;
|
|
|
10
10
|
*
|
|
11
11
|
* `start` opens it once, even when requests or Worker calls ask at the same time. An open that
|
|
12
12
|
* fails for a reason in the Tenant (a `TenantOpenError`, or a timeout) is kept as the Tenant's
|
|
13
|
-
* cause: the Host is not ready,
|
|
13
|
+
* cause: the Host is not ready, `nylorun-operate status` names it, requests get the opaque 404.
|
|
14
14
|
* One that fails outside the Tenant (`TenantUnavailableError`) is retried in the background
|
|
15
15
|
* with backoff, and by the next request, which gets 503 meanwhile.
|
|
16
16
|
*/
|
|
@@ -148,18 +148,9 @@ export function createTenantModule(options) {
|
|
|
148
148
|
return { id: null, name: null, state: "unavailable", envelope: null };
|
|
149
149
|
}
|
|
150
150
|
},
|
|
151
|
-
|
|
151
|
+
harnessStatus() {
|
|
152
152
|
const now = current();
|
|
153
|
-
|
|
154
|
-
const relay = await options.relayStatus?.().catch(() => undefined);
|
|
155
|
-
return {
|
|
156
|
-
runningSessions: summary?.runningSessions ?? 0,
|
|
157
|
-
inFlightDeliveries: summary?.inFlightDeliveries ?? 0,
|
|
158
|
-
pendingActions: summary?.pendingActions ?? 0,
|
|
159
|
-
uncertainEffects: summary?.uncertainEffects ?? 0,
|
|
160
|
-
...(relay ? { relay } : {}),
|
|
161
|
-
...(summary?.harness ? { harness: summary.harness } : {}),
|
|
162
|
-
};
|
|
153
|
+
return now.kind === "open" ? now.handle.harnessStatus?.() : undefined;
|
|
163
154
|
},
|
|
164
155
|
async close() {
|
|
165
156
|
const previous = current();
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Tenant's keys by name (F9 I1), managed through the Management API (`/v1/tenant/keys`,
|
|
3
|
+
* application keys only) and `nylorun-operate keys`. A key is a principal (F9-D11): its id is the key's name
|
|
4
|
+
* (`APPLICATION_KEY_ID_PATTERN`), the key is 64 hex characters (`mintBearerToken`), and the
|
|
5
|
+
* Tenant keeps only its SHA-256. Putting a key creates the principal or rotates its key;
|
|
6
|
+
* deleting it removes the row. Either way the old key stops authenticating on its next request,
|
|
7
|
+
* since authentication reads the principal on every request (`auth.ts`).
|
|
8
|
+
*
|
|
9
|
+
* `studio` is derived from the admin key (F9-D9): it is never put or deleted here. `bootstrap` is
|
|
10
|
+
* the management key a Host registers from `NYLORUN_MANAGEMENT_KEY_FILE`: its file is its only
|
|
11
|
+
* source. A key has a role (protocol 8): `application` reaches the Runtime API, `management` the
|
|
12
|
+
* Management API; a key keeps its role when it is rotated.
|
|
13
|
+
*/
|
|
14
|
+
import { type KeyRole } from "@nylorun/core/compatibility";
|
|
15
|
+
import type { OperatorKey, PutOperatorKeyResponse } from "@nylorun/core/contracts";
|
|
16
|
+
import type { SessionStore } from "../store/types.js";
|
|
17
|
+
/**
|
|
18
|
+
* Why a key is refused: not a key name, a reserved id (`studio`, `bootstrap`), or an id that
|
|
19
|
+
* holds the other role (`role`).
|
|
20
|
+
*/
|
|
21
|
+
export type OperatorKeyRefusal = {
|
|
22
|
+
reason: "invalid" | "reserved" | "role";
|
|
23
|
+
message: string;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* The refusal for `id`, or undefined when it may be `put` or deleted. `bootstrap` is never put
|
|
27
|
+
* here (its file is its only source) but may be deleted on the machine (AP18).
|
|
28
|
+
*/
|
|
29
|
+
export declare function refuseOperatorKeyId(id: string, purpose?: "put" | "delete"): OperatorKeyRefusal | undefined;
|
|
30
|
+
export interface OperatorKeys {
|
|
31
|
+
/** Every principal of the Tenant, by id (Studio's and derived ones included). */
|
|
32
|
+
list(): Promise<OperatorKey[]>;
|
|
33
|
+
/**
|
|
34
|
+
* Creates key `id` with `role` (default `application`) or rotates it; the key is returned
|
|
35
|
+
* this once. An id that holds the other role is refused.
|
|
36
|
+
*/
|
|
37
|
+
put(id: string, role?: Exclude<KeyRole, "studio">): Promise<PutOperatorKeyResponse | OperatorKeyRefusal>;
|
|
38
|
+
/**
|
|
39
|
+
* Deletes key `id`: false when there is none. With `role`, a key of another role is refused
|
|
40
|
+
* (the Management API deletes application keys only).
|
|
41
|
+
*/
|
|
42
|
+
delete(id: string, role?: Exclude<KeyRole, "studio">): Promise<boolean | OperatorKeyRefusal>;
|
|
43
|
+
}
|
|
44
|
+
export declare function operatorKeys(store: SessionStore, now?: () => string): OperatorKeys;
|