nylorun 0.3.0-beta → 0.5.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 (41) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +154 -68
  3. package/dist/cli.js +29 -25
  4. package/dist/doctor.d.ts +7 -5
  5. package/dist/doctor.js +58 -29
  6. package/dist/home.d.ts +1 -1
  7. package/dist/home.js +1 -1
  8. package/dist/project/derived-key.d.ts +8 -0
  9. package/dist/project/derived-key.js +18 -0
  10. package/dist/project/link.d.ts +32 -4
  11. package/dist/project/link.js +83 -14
  12. package/dist/project/seed.d.ts +27 -0
  13. package/dist/project/seed.js +111 -0
  14. package/dist/stack/commands.d.ts +42 -19
  15. package/dist/stack/commands.js +465 -152
  16. package/dist/stack/compose-file.d.ts +30 -11
  17. package/dist/stack/compose-file.js +88 -18
  18. package/dist/stack/docker.js +1 -1
  19. package/dist/stack/env-file.d.ts +24 -3
  20. package/dist/stack/env-file.js +49 -4
  21. package/dist/stack/host-files.d.ts +12 -3
  22. package/dist/stack/host-files.js +26 -3
  23. package/dist/stack/images.d.ts +1 -1
  24. package/dist/stack/images.js +1 -1
  25. package/dist/stack/index.d.ts +3 -3
  26. package/dist/stack/index.js +12 -3
  27. package/dist/stack/paths.d.ts +12 -4
  28. package/dist/stack/paths.js +9 -6
  29. package/dist/stack/ports.d.ts +2 -2
  30. package/dist/stack/ports.js +1 -1
  31. package/dist/stack/prepare.d.ts +13 -4
  32. package/dist/stack/prepare.js +25 -13
  33. package/dist/stack/restate-identity.d.ts +1 -1
  34. package/dist/stack/restate-identity.js +1 -1
  35. package/dist/stack/stacks.d.ts +50 -0
  36. package/dist/stack/stacks.js +150 -0
  37. package/dist/stack/versions.d.ts +1 -1
  38. package/dist/stack/versions.js +1 -1
  39. package/dist/telemetry.d.ts +31 -0
  40. package/dist/telemetry.js +79 -0
  41. package/package.json +5 -5
@@ -1,17 +1,36 @@
1
1
  /**
2
- * The local stack's Compose file (Runtime Architecture §14.3), written to
3
- * `<Host root>/stack/compose.yaml` by `nylorun start`. It is the same for every
4
- * machine: everything that varies (ports, the Postgres password, UID/GID, the
5
- * Host root, the Runtime and Studio images) comes from `stack/.env`.
2
+ * A local Tenant's Compose file (Runtime Architecture §14.3), written to
3
+ * `<Host root>/docker/compose.yaml` by `nylorun start`. Each local Tenant is one installation
4
+ * (tenancy.md §6): its own Compose project (`nylorun-<name>`, the file's `name`), volumes,
5
+ * network and ports. Everything else that varies (ports, the Postgres password, UID/GID, the
6
+ * Host root, the images, the Tenant's name) comes from `docker/.env`.
6
7
  *
7
- * Tenants are Postgres schemas, executed through Restate, with their history in
8
- * s2-lite; the Runtime's /ready checks all three. Postgres runs with
9
- * `wal_level=logical`: the stream relay feeds s2-lite from the record over logical
10
- * replication (Durable Streams), and `max_slot_wal_keep_size` caps the WAL a stuck
11
- * relay can hold.
8
+ * The Tenant's state is its Postgres database, executed through Restate, with its
9
+ * history in s2-lite; the Runtime's /ready checks all three. Postgres initialises the
10
+ * database with C collation (`--locale=C`) and runs with `wal_level=logical`: the stream
11
+ * relay feeds s2-lite from the record over logical replication (Durable Streams), and
12
+ * `max_slot_wal_keep_size` caps the WAL a stuck relay can hold. The Runtime creates the
13
+ * Tenant on its first start, with its name (`NYLORUN_TENANT_NAME`) and the derived
14
+ * principals of `NYLORUN_DERIVED_PRINCIPALS` (`project` for the Project link).
12
15
  *
13
16
  * Restate signs requests to the Worker endpoint with the private key in
14
- * `stack/restate-identity.pem`, mounted read-only; the Runtime gets the public
17
+ * `docker/restate-identity.pem`, mounted read-only; the Runtime gets the public
15
18
  * key as NYLORUN_RESTATE_IDENTITY_KEY.
19
+ *
20
+ * The combined packing (blueprint D12): the `runtime` container runs the core
21
+ * and loop services, and the `gateway` container runs gates: the Model Gate,
22
+ * which alone reads model credentials, and the Tool Gate (F4.1), which alone
23
+ * holds remote MCP connections and their credentials and POSTs every Action
24
+ * delivery. Every model call, remote MCP call and delivery of the loop crosses it
25
+ * (NYLORUN_GATES_URL, with NYLORUN_GATES_TOKEN from `.env`); stdio MCP servers
26
+ * still run in the runtime container. The gateway also runs keys (F4.2): the only
27
+ * holder of the vault key, it runs every vault write that touches a secret and signs
28
+ * every token (NYLORUN_KEYS_URL). The gateway mounts only the Host's Tenant directory
29
+ * (`tenant/`) and its keys directory (`keys/`, the vault key), read-only: never
30
+ * host-credentials.json. The runtime mounts the Host root with `keys/` and `docker/`
31
+ * covered by empty read-only mounts, so it can read neither the vault key nor
32
+ * Restate's private key and `.env`. The runtime does not wait for the gateway: while
33
+ * it is down, model and MCP calls fail, deliveries are retried, vault writes and
34
+ * token minting answer 503, and the session takes the next message.
16
35
  */
17
- export declare function renderComposeFile(): string;
36
+ export declare function renderComposeFile(project: string): string;
@@ -1,23 +1,42 @@
1
1
  import { PINNED_IMAGES } from "./images.js";
2
2
  /**
3
- * The local stack's Compose file (Runtime Architecture §14.3), written to
4
- * `<Host root>/stack/compose.yaml` by `nylorun start`. It is the same for every
5
- * machine: everything that varies (ports, the Postgres password, UID/GID, the
6
- * Host root, the Runtime and Studio images) comes from `stack/.env`.
3
+ * A local Tenant's Compose file (Runtime Architecture §14.3), written to
4
+ * `<Host root>/docker/compose.yaml` by `nylorun start`. Each local Tenant is one installation
5
+ * (tenancy.md §6): its own Compose project (`nylorun-<name>`, the file's `name`), volumes,
6
+ * network and ports. Everything else that varies (ports, the Postgres password, UID/GID, the
7
+ * Host root, the images, the Tenant's name) comes from `docker/.env`.
7
8
  *
8
- * Tenants are Postgres schemas, executed through Restate, with their history in
9
- * s2-lite; the Runtime's /ready checks all three. Postgres runs with
10
- * `wal_level=logical`: the stream relay feeds s2-lite from the record over logical
11
- * replication (Durable Streams), and `max_slot_wal_keep_size` caps the WAL a stuck
12
- * relay can hold.
9
+ * The Tenant's state is its Postgres database, executed through Restate, with its
10
+ * history in s2-lite; the Runtime's /ready checks all three. Postgres initialises the
11
+ * database with C collation (`--locale=C`) and runs with `wal_level=logical`: the stream
12
+ * relay feeds s2-lite from the record over logical replication (Durable Streams), and
13
+ * `max_slot_wal_keep_size` caps the WAL a stuck relay can hold. The Runtime creates the
14
+ * Tenant on its first start, with its name (`NYLORUN_TENANT_NAME`) and the derived
15
+ * principals of `NYLORUN_DERIVED_PRINCIPALS` (`project` for the Project link).
13
16
  *
14
17
  * Restate signs requests to the Worker endpoint with the private key in
15
- * `stack/restate-identity.pem`, mounted read-only; the Runtime gets the public
18
+ * `docker/restate-identity.pem`, mounted read-only; the Runtime gets the public
16
19
  * key as NYLORUN_RESTATE_IDENTITY_KEY.
20
+ *
21
+ * The combined packing (blueprint D12): the `runtime` container runs the core
22
+ * and loop services, and the `gateway` container runs gates: the Model Gate,
23
+ * which alone reads model credentials, and the Tool Gate (F4.1), which alone
24
+ * holds remote MCP connections and their credentials and POSTs every Action
25
+ * delivery. Every model call, remote MCP call and delivery of the loop crosses it
26
+ * (NYLORUN_GATES_URL, with NYLORUN_GATES_TOKEN from `.env`); stdio MCP servers
27
+ * still run in the runtime container. The gateway also runs keys (F4.2): the only
28
+ * holder of the vault key, it runs every vault write that touches a secret and signs
29
+ * every token (NYLORUN_KEYS_URL). The gateway mounts only the Host's Tenant directory
30
+ * (`tenant/`) and its keys directory (`keys/`, the vault key), read-only: never
31
+ * host-credentials.json. The runtime mounts the Host root with `keys/` and `docker/`
32
+ * covered by empty read-only mounts, so it can read neither the vault key nor
33
+ * Restate's private key and `.env`. The runtime does not wait for the gateway: while
34
+ * it is down, model and MCP calls fail, deliveries are retried, vault writes and
35
+ * token minting answer 503, and the session takes the next message.
17
36
  */
18
- export function renderComposeFile() {
37
+ export function renderComposeFile(project) {
19
38
  return `# Written by \`nylorun start\`; rewritten on every start. Settings live in .env.
20
- name: nylorun
39
+ name: ${project}
21
40
 
22
41
  services:
23
42
  postgres: # Session Store
@@ -28,6 +47,7 @@ services:
28
47
  POSTGRES_USER: nylorun
29
48
  POSTGRES_PASSWORD: \${NYLORUN_POSTGRES_PASSWORD:?run nylorun start}
30
49
  POSTGRES_DB: nylorun
50
+ POSTGRES_INITDB_ARGS: "--locale=C" # C collation for the whole database
31
51
  volumes:
32
52
  - postgres:/var/lib/postgresql/data
33
53
  healthcheck:
@@ -44,7 +64,7 @@ services:
44
64
  RESTATE_WORKER__INVOKER__REQUEST_IDENTITY_PRIVATE_KEY_PEM_FILE: /run/nylorun/restate-identity.pem
45
65
  volumes:
46
66
  - restate:/restate-data
47
- - \${NYLORUN_HOST_ROOT:?run nylorun start}/stack/restate-identity.pem:/run/nylorun/restate-identity.pem:ro
67
+ - \${NYLORUN_HOST_ROOT:?run nylorun start}/docker/restate-identity.pem:/run/nylorun/restate-identity.pem:ro
48
68
  ports:
49
69
  - "127.0.0.1:\${NYLORUN_RESTATE_PORT:?run nylorun start}:9070" # Restate UI and admin, for debugging
50
70
  healthcheck:
@@ -66,9 +86,37 @@ services:
66
86
  # the Runtime's /ready covers it.
67
87
  restart: unless-stopped
68
88
 
89
+ gateway: # the Model and Tool Gates and keys: the vault key, credentials, signing, outbound calls; not published
90
+ image: \${NYLORUN_RUNTIME_IMAGE:?run nylorun start}
91
+ command: ["--service", "gates,keys"]
92
+ user: "\${NYLORUN_UID:?run nylorun start}:\${NYLORUN_GID:?run nylorun start}"
93
+ depends_on:
94
+ postgres: { condition: service_healthy }
95
+ environment:
96
+ NYLORUN_HOME: /nylorun
97
+ NYLORUN_PACKING: combined
98
+ NYLORUN_GATES_LISTEN_PORT: "4100"
99
+ NYLORUN_GATES_ALLOWED_HOSTS: gateway:4100
100
+ NYLORUN_GATES_TOKEN: \${NYLORUN_GATES_TOKEN:?run nylorun start}
101
+ NYLORUN_DATABASE_URL: postgres://nylorun:\${NYLORUN_POSTGRES_PASSWORD}@postgres:5432/nylorun
102
+ # Action endpoints on this machine: \`localhost\` in a registered URL means the Docker host.
103
+ NYLORUN_ENDPOINT_LOOPBACK: docker-host
104
+ extra_hosts:
105
+ host.docker.internal: host-gateway # model servers, MCP servers and Action endpoints on this machine
106
+ volumes:
107
+ # The Tenant's homes and its vault key only, read-only.
108
+ - \${NYLORUN_HOST_ROOT:?run nylorun start}/tenant:/nylorun/tenant:ro
109
+ - \${NYLORUN_HOST_ROOT:?run nylorun start}/keys:/nylorun/keys:ro
110
+ healthcheck:
111
+ test: ["CMD", "node", "-e", "fetch('http://localhost:4100/ready').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
112
+ interval: 2s
113
+ timeout: 5s
114
+ retries: 60
115
+ restart: unless-stopped
116
+
69
117
  runtime:
70
118
  image: \${NYLORUN_RUNTIME_IMAGE:?run nylorun start}
71
- command: ["--role", "all"]
119
+ command: ["--service", "core,loop"]
72
120
  user: "\${NYLORUN_UID:?run nylorun start}:\${NYLORUN_GID:?run nylorun start}"
73
121
  depends_on:
74
122
  postgres: { condition: service_healthy }
@@ -76,14 +124,24 @@ services:
76
124
  s2: { condition: service_started }
77
125
  environment:
78
126
  NYLORUN_HOME: /nylorun
127
+ NYLORUN_PACKING: combined
128
+ # The Tenant the Runtime creates on its first start (later starts open it).
129
+ NYLORUN_TENANT_NAME: \${NYLORUN_TENANT_NAME:?run nylorun start}
130
+ NYLORUN_DERIVED_PRINCIPALS: \${NYLORUN_DERIVED_PRINCIPALS:-project}
131
+ # Model calls, remote MCP calls and deliveries go through the gateway, and vault writes
132
+ # and token signing through its keys service: this container never reads a credential
133
+ # or the vault key.
134
+ NYLORUN_GATES_URL: http://gateway:4100
135
+ NYLORUN_KEYS_URL: http://gateway:4100
136
+ NYLORUN_GATES_TOKEN: \${NYLORUN_GATES_TOKEN:?run nylorun start}
79
137
  NYLORUN_LISTEN_HOST: 0.0.0.0
80
138
  NYLORUN_LISTEN_PORT: "4000"
81
- # Host headers the Runtime accepts: the stack network name, and the
139
+ # Host headers the Runtime accepts: its name on the Compose network, and the
82
140
  # published port as clients on this machine address it.
83
141
  NYLORUN_ALLOWED_HOSTS: runtime:4000,localhost:\${NYLORUN_PORT},127.0.0.1:\${NYLORUN_PORT}
84
142
  NYLORUN_PUBLIC_URL: http://localhost:\${NYLORUN_PORT} # reported by /v1/admin/status
85
143
  # The Admin API on its own listener, published on loopback only; Studio reaches it on
86
- # the stack network. Port 4000 serves the Tenant API alone.
144
+ # the Compose network. Port 4000 serves the Tenant API alone.
87
145
  NYLORUN_ADMIN_LISTEN_PORT: "4001"
88
146
  NYLORUN_ADMIN_ALLOWED_HOSTS: runtime:4001,localhost:\${NYLORUN_ADMIN_PORT},127.0.0.1:\${NYLORUN_ADMIN_PORT}
89
147
  NYLORUN_DATABASE_URL: postgres://nylorun:\${NYLORUN_POSTGRES_PASSWORD}@postgres:5432/nylorun
@@ -100,12 +158,22 @@ services:
100
158
  host.docker.internal: host-gateway # the Docker host, also on Linux Docker Engine
101
159
  volumes:
102
160
  - \${NYLORUN_HOST_ROOT:?run nylorun start}:/nylorun # Host root
161
+ # Empty and read-only over the vault key (keys/) and the Compose secrets (docker/):
162
+ # only the gateway reads the key, and only Restate its private key.
163
+ - type: tmpfs
164
+ target: /nylorun/keys
165
+ read_only: true
166
+ tmpfs: { size: 4096, mode: 0755 } # empty, and listable by the runtime user
167
+ - type: tmpfs
168
+ target: /nylorun/docker
169
+ read_only: true
170
+ tmpfs: { size: 4096, mode: 0755 } # empty, and listable by the runtime user
103
171
  - workspaces:/workspaces
104
172
  ports:
105
173
  - "127.0.0.1:\${NYLORUN_PORT:?run nylorun start}:4000" # Tenant API, SSE, browsers
106
174
  - "127.0.0.1:\${NYLORUN_ADMIN_PORT:?run nylorun start}:4001" # Admin API (operators only)
107
- healthcheck:
108
- test: ["CMD", "node", "-e", "fetch('http://localhost:4000/ready').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
175
+ healthcheck: # liveness: a Tenant that cannot open is reported by nylorun start from the Admin status, not by a 300 s wait
176
+ test: ["CMD", "node", "-e", "fetch('http://localhost:4000/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
109
177
  interval: 2s
110
178
  timeout: 5s
111
179
  retries: 60
@@ -124,6 +192,8 @@ services:
124
192
  NYLORUN_STUDIO_PUBLIC_PORT: \${NYLORUN_STUDIO_PORT}
125
193
  # Exact origins that may frame Studio (Babai Desktop); kept in .env.
126
194
  NYLORUN_STUDIO_FRAME_ANCESTORS: \${NYLORUN_STUDIO_FRAME_ANCESTORS:-}
195
+ # Studio's anonymous usage analytics; empty when telemetry is off.
196
+ NYLORUN_STUDIO_ANALYTICS_ID: \${NYLORUN_STUDIO_ANALYTICS_ID:-}
127
197
  volumes:
128
198
  - \${NYLORUN_HOST_ROOT}/host-credentials.json:/run/nylorun/host-credentials.json:ro
129
199
  ports:
@@ -45,7 +45,7 @@ export async function checkDocker(docker) {
45
45
  return {
46
46
  docker: {
47
47
  ok: false,
48
- problem: "Docker is required for the Nylorun stack, and the docker command was not found. Install Docker Desktop, OrbStack, Colima or another Docker engine with Compose v2, then retry.",
48
+ problem: "Docker is required for local Nylorun Tenants, and the docker command was not found. Install Docker Desktop, OrbStack, Colima or another Docker engine with Compose v2, then retry.",
49
49
  },
50
50
  };
51
51
  if (server.code !== 0)
@@ -4,8 +4,8 @@
4
4
  */
5
5
  export declare const DEFAULT_STUDIO_FRAME_ANCESTORS: readonly ["nylorun://localhost", "http://nylorun.localhost"];
6
6
  /**
7
- * Settings in `stack/.env` (mode 0600). Ports and the password persist; the
8
- * Restate identity public key is derived from `restate-identity.pem`.
7
+ * Settings in `docker/.env` (mode 0600). Ports, the password and the gates token
8
+ * persist; the Restate identity public key is derived from `restate-identity.pem`.
9
9
  */
10
10
  export interface StackEnv {
11
11
  /** Published Runtime port (loopback): the Tenant API. */
@@ -17,7 +17,9 @@ export interface StackEnv {
17
17
  /** Published Restate UI and admin port (loopback). */
18
18
  restatePort: number;
19
19
  postgresPassword: string;
20
- /** `publickeyv1_...` of `stack/restate-identity.pem`. */
20
+ /** The token the runtime presents to the gateway (`NYLORUN_GATES_TOKEN`), 32 bytes as hex. */
21
+ gatesToken: string;
22
+ /** `publickeyv1_...` of `docker/restate-identity.pem`. */
21
23
  restateIdentityKey: string;
22
24
  uid: number;
23
25
  gid: number;
@@ -27,8 +29,25 @@ export interface StackEnv {
27
29
  studioImage: string;
28
30
  /** Exact origins that may frame Studio, separated by spaces (persists). */
29
31
  studioFrameAncestors: string;
32
+ /**
33
+ * The Google Analytics measurement id Studio reports page views to, or empty
34
+ * when telemetry is off. Decided again on every start.
35
+ */
36
+ studioAnalyticsId: string;
37
+ /** The Tenant's name, which the Runtime gives the Tenant it creates. */
38
+ tenantName: string;
39
+ /**
40
+ * Derived principals the Runtime registers on its Tenant, comma-separated (persists);
41
+ * always includes `project`, the Project link's principal.
42
+ */
43
+ derivedPrincipals: string;
30
44
  }
31
45
  export declare function renderEnvFile(env: StackEnv): string;
46
+ /**
47
+ * `NYLORUN_DERIVED_PRINCIPALS`: comma-separated principal ids, as the Runtime accepts them
48
+ * (not `studio`), with `project` first so the Project link's key always works.
49
+ */
50
+ export declare function parseDerivedPrincipals(raw: string, source: string): string[];
32
51
  /** Parse KEY=value lines; comments and blank lines are skipped. */
33
52
  export declare function parseEnvLines(text: string): Map<string, string>;
34
53
  /** The settings that persist across starts, from an existing .env. */
@@ -38,7 +57,9 @@ export interface PersistedStackEnv {
38
57
  studioPort?: number;
39
58
  restatePort?: number;
40
59
  postgresPassword?: string;
60
+ gatesToken?: string;
41
61
  /** Validated origins; absent when the line is missing (an older .env). */
42
62
  studioFrameAncestors?: string[];
63
+ derivedPrincipals?: string[];
43
64
  }
44
65
  export declare function parsePersisted(text: string): PersistedStackEnv;
@@ -1,3 +1,4 @@
1
+ import { DERIVED_PRINCIPAL_ID_PATTERN } from "@nylorun/core/compatibility";
1
2
  import { parseFrameAncestors } from "@nylorun/core/contracts";
2
3
  import { CliError } from "../errors.js";
3
4
  /**
@@ -14,6 +15,7 @@ const KEYS = {
14
15
  studioPort: "NYLORUN_STUDIO_PORT",
15
16
  restatePort: "NYLORUN_RESTATE_PORT",
16
17
  postgresPassword: "NYLORUN_POSTGRES_PASSWORD",
18
+ gatesToken: "NYLORUN_GATES_TOKEN",
17
19
  restateIdentityKey: "NYLORUN_RESTATE_IDENTITY_KEY",
18
20
  uid: "NYLORUN_UID",
19
21
  gid: "NYLORUN_GID",
@@ -21,6 +23,9 @@ const KEYS = {
21
23
  runtimeImage: "NYLORUN_RUNTIME_IMAGE",
22
24
  studioImage: "NYLORUN_STUDIO_IMAGE",
23
25
  studioFrameAncestors: "NYLORUN_STUDIO_FRAME_ANCESTORS",
26
+ studioAnalyticsId: "NYLORUN_STUDIO_ANALYTICS_ID",
27
+ tenantName: "NYLORUN_TENANT_NAME",
28
+ derivedPrincipals: "NYLORUN_DERIVED_PRINCIPALS",
24
29
  };
25
30
  /** Compose .env values: single quotes keep a value literal (no interpolation). */
26
31
  function quote(key, value) {
@@ -39,9 +44,9 @@ function unquote(value) {
39
44
  export function renderEnvFile(env) {
40
45
  const line = (field) => `${KEYS[field]}=${quote(KEYS[field], String(env[field]))}`;
41
46
  return [
42
- "# Written by `nylorun start`. Mode 0600: holds the Postgres password.",
43
- "# Ports and the password are kept across starts; images, UID/GID and the",
44
- "# Host root are refreshed on every start.",
47
+ "# Written by `nylorun start`. Mode 0600: holds the Postgres password and the",
48
+ "# gates token. Ports, the password and the token are kept across starts;",
49
+ "# images, UID/GID and the Host root are refreshed on every start.",
45
50
  "",
46
51
  "# Published on 127.0.0.1; clients use http://localhost:<port>.",
47
52
  line("runtimePort"),
@@ -51,6 +56,10 @@ export function renderEnvFile(env) {
51
56
  "",
52
57
  line("postgresPassword"),
53
58
  "",
59
+ "# The runtime presents this token to the gateway (the Model Gate) with every",
60
+ "# model call.",
61
+ line("gatesToken"),
62
+ "",
54
63
  "# Restate signs requests to the Runtime's Worker endpoint with the private",
55
64
  "# key in restate-identity.pem; the Runtime accepts only this public key.",
56
65
  line("restateIdentityKey"),
@@ -68,8 +77,32 @@ export function renderEnvFile(env) {
68
77
  "# starts; change with nylorun start --studio-embed-origin <origin>.",
69
78
  line("studioFrameAncestors"),
70
79
  "",
80
+ "# Studio's anonymous usage analytics; empty when off. Set on every start: turn",
81
+ "# it off with nylorun telemetry disable or NYLORUN_TELEMETRY_DISABLED=1.",
82
+ line("studioAnalyticsId"),
83
+ "",
84
+ "# The Tenant's name, which the Runtime gives the Tenant it creates on the first",
85
+ "# start, and the derived principals it registers on that Tenant (keys derived",
86
+ "# from the admin key; `project` is the Project link's).",
87
+ line("tenantName"),
88
+ line("derivedPrincipals"),
89
+ "",
71
90
  ].join("\n");
72
91
  }
92
+ /**
93
+ * `NYLORUN_DERIVED_PRINCIPALS`: comma-separated principal ids, as the Runtime accepts them
94
+ * (not `studio`), with `project` first so the Project link's key always works.
95
+ */
96
+ export function parseDerivedPrincipals(raw, source) {
97
+ const ids = raw
98
+ .split(",")
99
+ .map((entry) => entry.trim())
100
+ .filter((entry) => entry !== "");
101
+ for (const id of ids)
102
+ if (!DERIVED_PRINCIPAL_ID_PATTERN.test(id) || id === "studio")
103
+ throw new CliError(`${source} has '${id}': each entry must match ${DERIVED_PRINCIPAL_ID_PATTERN} and not be studio.`, 2);
104
+ return [...new Set(["project", ...ids])];
105
+ }
73
106
  /** Parse KEY=value lines; comments and blank lines are skipped. */
74
107
  export function parseEnvLines(text) {
75
108
  const values = new Map();
@@ -108,13 +141,25 @@ export function parsePersisted(text) {
108
141
  const password = values.get(KEYS.postgresPassword);
109
142
  if (password && /^[A-Za-z0-9]{16,}$/.test(password))
110
143
  out.postgresPassword = password;
144
+ const gatesToken = values.get(KEYS.gatesToken);
145
+ if (gatesToken && /^[0-9a-f]{64,}$/i.test(gatesToken))
146
+ out.gatesToken = gatesToken;
147
+ const principals = values.get(KEYS.derivedPrincipals);
148
+ if (principals !== undefined) {
149
+ try {
150
+ out.derivedPrincipals = parseDerivedPrincipals(principals, `${KEYS.derivedPrincipals} in docker/.env`);
151
+ }
152
+ catch {
153
+ /* rewritten from the default on the next start */
154
+ }
155
+ }
111
156
  const ancestors = values.get(KEYS.studioFrameAncestors);
112
157
  if (ancestors !== undefined) {
113
158
  try {
114
159
  out.studioFrameAncestors = parseFrameAncestors(ancestors);
115
160
  }
116
161
  catch (error) {
117
- throw new CliError(`${KEYS.studioFrameAncestors} in stack/.env: ${error instanceof Error ? error.message : String(error)}`, 1);
162
+ throw new CliError(`${KEYS.studioFrameAncestors} in docker/.env: ${error instanceof Error ? error.message : String(error)}`, 1);
118
163
  }
119
164
  }
120
165
  return out;
@@ -3,17 +3,17 @@ import type { StackPaths } from "./paths.js";
3
3
  * host.json and host-credentials.json (format 1), which `@nylorun/admin`,
4
4
  * Studio and the Runtime container all read.
5
5
  *
6
- * For the stack, host.json names the client-facing address: `localhost` and
6
+ * For a local Tenant, host.json names the client-facing address: `localhost` and
7
7
  * the published port. The container binds 0.0.0.0:4000 on its own.
8
8
  */
9
- /** Host name clients use for the stack. Studio's cookie is set on this name too. */
9
+ /** Host name clients use for a local Tenant. Studio's cookie is set on this name too. */
10
10
  export declare const STACK_CLIENT_HOST = "localhost";
11
11
  export interface HostConfigFile {
12
12
  format: 1;
13
13
  hostId: string;
14
14
  host: string;
15
15
  port: number;
16
- /** The operator listener's published port (the Admin API); absent on older stacks. */
16
+ /** The operator listener's published port (the Admin API); absent on older Host roots. */
17
17
  adminPort?: number;
18
18
  runtimeVersion?: string;
19
19
  [field: string]: unknown;
@@ -33,6 +33,15 @@ export declare function writeStackHostConfig(paths: StackPaths, input: {
33
33
  runtimeVersion: string | undefined;
34
34
  }): Promise<HostConfigFile>;
35
35
  export declare function readAdminKey(paths: StackPaths): Promise<string | undefined>;
36
+ /**
37
+ * The Tenant's vault key in `keys/vault-kek` (32 random bytes, base64, mode 0600), which only the
38
+ * gateway container mounts (F4.2): created if missing, or moved from `tenant/vault-kek`, where a
39
+ * Host root before F4.2 kept it. The gateway never creates it.
40
+ */
41
+ export declare function ensureVaultKey(paths: StackPaths): Promise<{
42
+ created: boolean;
43
+ moved: boolean;
44
+ }>;
36
45
  /** Create host-credentials.json (mode 0600) if missing; always leave it 0600. */
37
46
  export declare function ensureHostCredentials(paths: StackPaths): Promise<{
38
47
  adminKey: string;
@@ -1,15 +1,17 @@
1
1
  import { randomBytes } from "node:crypto";
2
+ import { existsSync } from "node:fs";
2
3
  import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
3
5
  import { newTenantId } from "@nylorun/core/compatibility";
4
6
  import { CliError } from "../errors.js";
5
7
  /**
6
8
  * host.json and host-credentials.json (format 1), which `@nylorun/admin`,
7
9
  * Studio and the Runtime container all read.
8
10
  *
9
- * For the stack, host.json names the client-facing address: `localhost` and
11
+ * For a local Tenant, host.json names the client-facing address: `localhost` and
10
12
  * the published port. The container binds 0.0.0.0:4000 on its own.
11
13
  */
12
- /** Host name clients use for the stack. Studio's cookie is set on this name too. */
14
+ /** Host name clients use for a local Tenant. Studio's cookie is set on this name too. */
13
15
  export const STACK_CLIENT_HOST = "localhost";
14
16
  const KNOWN_FORMAT = 1;
15
17
  const ADMIN_KEY_PATTERN = /^[0-9a-f]{64}$/;
@@ -24,7 +26,7 @@ async function writeAtomic(path, text) {
24
26
  }
25
27
  /** The Host root layout, mode 0700. */
26
28
  export async function ensureHostLayout(paths) {
27
- for (const dir of [paths.root, paths.home, paths.tmp, paths.tenants, paths.stack])
29
+ for (const dir of [paths.root, paths.home, paths.tmp, paths.tenant, paths.keys, paths.docker])
28
30
  await mkdir(dir, { recursive: true, mode: 0o700 });
29
31
  }
30
32
  export async function readHostConfig(paths) {
@@ -82,6 +84,27 @@ export async function readAdminKey(paths) {
82
84
  return undefined;
83
85
  }
84
86
  }
87
+ /**
88
+ * The Tenant's vault key in `keys/vault-kek` (32 random bytes, base64, mode 0600), which only the
89
+ * gateway container mounts (F4.2): created if missing, or moved from `tenant/vault-kek`, where a
90
+ * Host root before F4.2 kept it. The gateway never creates it.
91
+ */
92
+ export async function ensureVaultKey(paths) {
93
+ await mkdir(paths.keys, { recursive: true, mode: 0o700 });
94
+ await chmod(paths.keys, 0o700);
95
+ if (existsSync(paths.vaultKey)) {
96
+ await chmod(paths.vaultKey, 0o600);
97
+ return { created: false, moved: false };
98
+ }
99
+ const legacy = join(paths.tenant, "vault-kek");
100
+ if (existsSync(legacy)) {
101
+ await rename(legacy, paths.vaultKey);
102
+ await chmod(paths.vaultKey, 0o600);
103
+ return { created: false, moved: true };
104
+ }
105
+ await writeAtomic(paths.vaultKey, `${randomBytes(32).toString("base64")}\n`);
106
+ return { created: true, moved: false };
107
+ }
85
108
  /** Create host-credentials.json (mode 0600) if missing; always leave it 0600. */
86
109
  export async function ensureHostCredentials(paths) {
87
110
  const existing = await readAdminKey(paths);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Images the local stack runs. The Runtime and Studio tags are pinned by this
2
+ * Images a local Tenant runs. The Runtime and Studio tags are pinned by this
3
3
  * CLI release; `NYLORUN_RUNTIME_IMAGE` and `NYLORUN_STUDIO_IMAGE` override them
4
4
  * (local builds, CI images). Postgres, Restate and s2-lite are the official
5
5
  * images, pinned here.
@@ -1,6 +1,6 @@
1
1
  import { pinnedVersion } from "./versions.js";
2
2
  /**
3
- * Images the local stack runs. The Runtime and Studio tags are pinned by this
3
+ * Images a local Tenant runs. The Runtime and Studio tags are pinned by this
4
4
  * CLI release; `NYLORUN_RUNTIME_IMAGE` and `NYLORUN_STUDIO_IMAGE` override them
5
5
  * (local builds, CI images). Postgres, Restate and s2-lite are the official
6
6
  * images, pinned here.
@@ -1,13 +1,13 @@
1
1
  import { type StackDeps } from "./commands.js";
2
2
  export { ensureStack, isStackCommand, readStackStatus, stackUsage, studioLoginUrl, tenantStudioPath, STACK_SERVICES, } from "./commands.js";
3
- export type { StackDeps, StackEndpoints, StackStatus } from "./commands.js";
3
+ export type { StackDeps, StackEndpoints, StackStatus, StackTenant } from "./commands.js";
4
4
  export { checkDocker } from "./docker.js";
5
5
  export type { Check, DockerChecks } from "./docker.js";
6
6
  /** Real dependencies: docker on PATH, global fetch, the terminal. */
7
7
  export declare function defaultStackDeps(env: Readonly<Record<string, string | undefined>>): StackDeps;
8
- /** Entry for `nylorun start|stop|status|logs|reset|studio`. */
8
+ /** Entry for `nylorun start|stop|status|logs|reset|studio|ls|delete`. */
9
9
  export declare function stackCommand(name: string, args: readonly string[], env: Readonly<Record<string, string | undefined>>): Promise<number>;
10
- /** `nylorun studio`, landing on `next` (a Tenant page) when given. */
10
+ /** `nylorun studio`, landing on `next` when given, else on the Tenant's page. */
11
11
  export declare function studioCommand(args: readonly string[], env: Readonly<Record<string, string | undefined>>, options?: {
12
12
  next?: string;
13
13
  }): Promise<number>;
@@ -13,12 +13,21 @@ function browserCommand(env) {
13
13
  return "wslview";
14
14
  return "xdg-open";
15
15
  }
16
+ /**
17
+ * Variables `docker/.env` sets for Compose: `nylorun start` writes them there, so the
18
+ * environment must not override them when Compose interpolates the file.
19
+ */
20
+ const STACK_ENV_OWNED = [
21
+ "NYLORUN_TENANT_NAME",
22
+ "NYLORUN_DERIVED_PRINCIPALS",
23
+ "NYLORUN_STUDIO_ANALYTICS_ID",
24
+ ];
16
25
  /** Real dependencies: docker on PATH, global fetch, the terminal. */
17
26
  export function defaultStackDeps(env) {
18
27
  const interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY);
19
28
  return {
20
29
  env,
21
- docker: spawnDocker(env),
30
+ docker: spawnDocker(Object.fromEntries(Object.entries(env).filter(([key]) => !STACK_ENV_OWNED.includes(key)))),
22
31
  fetch: (input, init) => fetch(input, init),
23
32
  ports: loopbackPorts,
24
33
  uid: typeof process.getuid === "function" ? process.getuid() : 1000,
@@ -66,11 +75,11 @@ export function defaultStackDeps(env) {
66
75
  },
67
76
  };
68
77
  }
69
- /** Entry for `nylorun start|stop|status|logs|reset|studio`. */
78
+ /** Entry for `nylorun start|stop|status|logs|reset|studio|ls|delete`. */
70
79
  export async function stackCommand(name, args, env) {
71
80
  return await runStackCommand(name, args, defaultStackDeps(env));
72
81
  }
73
- /** `nylorun studio`, landing on `next` (a Tenant page) when given. */
82
+ /** `nylorun studio`, landing on `next` when given, else on the Tenant's page. */
74
83
  export async function studioCommand(args, env, options = {}) {
75
84
  return await runStudioCommand(args, defaultStackDeps(env), options);
76
85
  }
@@ -1,6 +1,6 @@
1
- /** Files under the Host root that the stack commands read and write. */
1
+ /** Files under the Host root that the nylorun commands read and write. */
2
2
  export interface StackPaths {
3
- /** The Host root (`~/.nylorun` or `NYLORUN_HOME`), bind-mounted at /nylorun. */
3
+ /** The Host root (`~/.nylorun/tenants/<name>` or `NYLORUN_HOME`), bind-mounted at /nylorun. */
4
4
  root: string;
5
5
  /** host.json: client-facing host and published port, no secrets. */
6
6
  config: string;
@@ -8,10 +8,18 @@ export interface StackPaths {
8
8
  credentials: string;
9
9
  /** host-state.json: written only by a launcher-managed Runtime. */
10
10
  state: string;
11
- tenants: string;
11
+ /** tenant.json: the Tenant's name and the project it was created for. */
12
+ record: string;
13
+ /** The Tenant directory: homes, sandboxes, plugin data, logs. */
14
+ tenant: string;
15
+ /** The keys directory, mounted only into the gateway (F4.2). */
16
+ keys: string;
17
+ /** The Tenant's vault key (KEK), `keys/vault-kek`. */
18
+ vaultKey: string;
12
19
  home: string;
13
20
  tmp: string;
14
- stack: string;
21
+ /** The Docker Compose files: compose.yaml, .env and the Restate identity key. */
22
+ docker: string;
15
23
  compose: string;
16
24
  env: string;
17
25
  /** Restate's request-identity private key (Ed25519 PKCS#8 PEM), mode 0600. */
@@ -1,18 +1,21 @@
1
1
  import { join, resolve } from "node:path";
2
2
  export function stackPaths(hostRoot) {
3
3
  const root = resolve(hostRoot);
4
- const stack = join(root, "stack");
4
+ const docker = join(root, "docker");
5
5
  return {
6
6
  root,
7
7
  config: join(root, "host.json"),
8
8
  credentials: join(root, "host-credentials.json"),
9
9
  state: join(root, "host-state.json"),
10
- tenants: join(root, "tenants"),
10
+ record: join(root, "tenant.json"),
11
+ tenant: join(root, "tenant"),
12
+ keys: join(root, "keys"),
13
+ vaultKey: join(root, "keys", "vault-kek"),
11
14
  home: join(root, "home"),
12
15
  tmp: join(root, "tmp"),
13
- stack,
14
- compose: join(stack, "compose.yaml"),
15
- env: join(stack, ".env"),
16
- restateIdentity: join(stack, "restate-identity.pem"),
16
+ docker,
17
+ compose: join(docker, "compose.yaml"),
18
+ env: join(docker, ".env"),
19
+ restateIdentity: join(docker, "restate-identity.pem"),
17
20
  };
18
21
  }
@@ -5,7 +5,7 @@ export declare const DEFAULT_PORTS: {
5
5
  readonly studio: 4161;
6
6
  readonly restate: 9070;
7
7
  };
8
- /** How the stack finds ports on 127.0.0.1; injectable for tests. */
8
+ /** How `nylorun start` finds ports on 127.0.0.1; injectable for tests. */
9
9
  export interface PortProbe {
10
10
  isFree(port: number): Promise<boolean>;
11
11
  /** A port the OS reports free right now. */
@@ -14,7 +14,7 @@ export interface PortProbe {
14
14
  export declare const loopbackPorts: PortProbe;
15
15
  /**
16
16
  * Choose a port: the persisted one if any (kept even when busy, because the
17
- * running stack may hold it), else the default when free, else a free one.
17
+ * running Tenant may hold it), else the default when free, else a free one.
18
18
  * Never returns a port in `taken`.
19
19
  */
20
20
  export declare function choosePort(probe: PortProbe, preferred: number, persisted: number | undefined, taken: ReadonlySet<number>): Promise<number>;