@forgezero/agent 0.1.24 → 0.1.26

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.
@@ -70,6 +70,7 @@ export interface InstallOptions {
70
70
  enrolTokenCredentialPath?: string;
71
71
  enrolStatePath?: string;
72
72
  nodeLabel?: string;
73
+ nodeHostname?: string;
73
74
  }
74
75
  export declare function planInstall(options: InstallOptions): ProvisionPlan;
75
76
  /** Execute the same ordered plan the control plane executes over SSH. */
package/dist/fz-agent.js CHANGED
@@ -25,30 +25,44 @@ class CacheError extends Error {
25
25
  var DEFAULT_TTL_MS = 60000;
26
26
  var DEFAULT_MAX_STALE_MS = 300000;
27
27
  function createSecretCache(options) {
28
- const entries = new Map;
28
+ let entries = new Map;
29
29
  const now = options.now ?? (() => Date.now());
30
30
  const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
31
31
  const maxStaleMs = options.maxStaleMs ?? DEFAULT_MAX_STALE_MS;
32
32
  let cursor = 0;
33
33
  let replicated = false;
34
34
  let lastSyncOkMs = now();
35
- const loadScope = async () => {
35
+ const fetchScope = async () => {
36
+ const next = new Map;
36
37
  if (!options.list)
37
- return { loaded: 0, failed: [] };
38
+ return { entries: next, loaded: 0, failed: [] };
38
39
  const names = await options.list();
39
40
  const failed = [];
40
- let loaded = 0;
41
41
  for (const name of names) {
42
42
  try {
43
43
  const result = await options.fetch(name);
44
- entries.set(name, { value: result.value, version: result.version, fetchedAtMs: now() });
45
- loaded += 1;
44
+ next.set(name, { value: result.value, version: result.version, fetchedAtMs: now() });
46
45
  } catch {
47
46
  failed.push(name);
48
47
  }
49
48
  }
49
+ return { entries: next, loaded: next.size, failed };
50
+ };
51
+ const loadScope = async () => {
52
+ if (!options.list)
53
+ return { loaded: 0, failed: [] };
54
+ const snapshot = await fetchScope();
55
+ entries = snapshot.entries;
56
+ replicated = snapshot.failed.length === 0;
57
+ return { loaded: snapshot.loaded, failed: snapshot.failed };
58
+ };
59
+ const refreshScope = async () => {
60
+ const snapshot = await fetchScope();
61
+ if (snapshot.failed.length > 0) {
62
+ throw new CacheError("FETCH_FAILED", `Could not refresh ${snapshot.failed.length} assigned vault ${snapshot.failed.length === 1 ? "entry" : "entries"}.`);
63
+ }
64
+ entries = snapshot.entries;
50
65
  replicated = true;
51
- return { loaded, failed };
52
66
  };
53
67
  return {
54
68
  names: () => [...entries.keys()],
@@ -82,13 +96,26 @@ function createSecretCache(options) {
82
96
  const result = await options.changes(cursor);
83
97
  if (result.resync) {
84
98
  const dropped = [...entries.keys()];
85
- entries.clear();
99
+ if (options.list) {
100
+ await refreshScope();
101
+ } else {
102
+ entries.clear();
103
+ }
86
104
  cursor = 0;
87
105
  lastSyncOkMs = now();
88
- if (options.list)
89
- await loadScope();
90
106
  return { invalidated: dropped, cursor: 0, resync: true };
91
107
  }
108
+ if (options.list && result.changed.length > 0) {
109
+ const held = new Set(entries.keys());
110
+ await refreshScope();
111
+ cursor = result.version;
112
+ lastSyncOkMs = now();
113
+ return {
114
+ invalidated: result.changed.filter((name) => held.has(name) || entries.has(name)),
115
+ cursor,
116
+ resync: false
117
+ };
118
+ }
92
119
  const invalidated = [];
93
120
  for (const name of result.changed) {
94
121
  if (entries.delete(name))
@@ -1225,6 +1252,7 @@ async function enrolGuestIdentity(options) {
1225
1252
  label: options.label,
1226
1253
  gitDeployPublicKey: options.gitDeployPublicKey,
1227
1254
  privateNetworkAttachment: options.privateNetworkAttachment,
1255
+ edgeHostname: options.edgeHostname?.trim() || undefined,
1228
1256
  publicKeys: {
1229
1257
  ed25519: options.keys.ed25519.publicKey,
1230
1258
  mlDsa: options.keys.mlDsa.publicKey
@@ -2717,17 +2745,23 @@ function validateLifecycleProfile(profile) {
2717
2745
  if (!Array.isArray(profile.apiUnits) || profile.apiUnits.length < 1 || profile.apiUnits.some((unit) => !unitPattern.test(unit))) {
2718
2746
  throw new Error("lifecycle profile needs one or more valid API service units");
2719
2747
  }
2720
- if (!unitPattern.test(profile.databaseUnit))
2748
+ const databaseValues = [profile.databaseUnit, profile.databaseHealthUrl, profile.databasePorts];
2749
+ if (databaseValues.some((value) => value !== undefined) && databaseValues.some((value) => value === undefined)) {
2750
+ throw new Error("database lifecycle settings must be supplied together");
2751
+ }
2752
+ if (profile.databaseUnit && !unitPattern.test(profile.databaseUnit))
2721
2753
  throw new Error("lifecycle profile database unit is invalid");
2722
2754
  const apiUrl = new URL(profile.apiHealthUrl);
2723
2755
  if (apiUrl.protocol !== "http:" || !["127.0.0.1", "[::1]", "::1", "localhost"].includes(apiUrl.hostname)) {
2724
2756
  throw new Error("API health URL must be loopback HTTP");
2725
2757
  }
2726
- const databaseUrl = new URL(profile.databaseHealthUrl);
2727
- if (databaseUrl.protocol !== "http:" || !(["127.0.0.1", "[::1]", "::1", "localhost"].includes(databaseUrl.hostname) || privateIp(databaseUrl.hostname))) {
2728
- throw new Error("database health URL must be loopback or private HTTP");
2758
+ if (profile.databaseHealthUrl) {
2759
+ const databaseUrl = new URL(profile.databaseHealthUrl);
2760
+ if (databaseUrl.protocol !== "http:" || !(["127.0.0.1", "[::1]", "::1", "localhost"].includes(databaseUrl.hostname) || privateIp(databaseUrl.hostname))) {
2761
+ throw new Error("database health URL must be loopback or private HTTP");
2762
+ }
2729
2763
  }
2730
- if (!Array.isArray(profile.databasePorts) || profile.databasePorts.length < 1 || profile.databasePorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535)) {
2764
+ if (profile.databasePorts && (!Array.isArray(profile.databasePorts) || profile.databasePorts.length < 1 || profile.databasePorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535))) {
2731
2765
  throw new Error("lifecycle profile database ports are invalid");
2732
2766
  }
2733
2767
  }
@@ -2786,7 +2820,7 @@ async function executeLifecycleAction(profile, claim, exec = spawnLifecycleComma
2786
2820
  throw new Error("WARP is not connected");
2787
2821
  }
2788
2822
  for (const address of claim.peerPrivateAddresses) {
2789
- for (const port of profile.databasePorts)
2823
+ for (const port of profile.databasePorts ?? [])
2790
2824
  await tcpProbe(address, port);
2791
2825
  }
2792
2826
  return {
@@ -2796,6 +2830,9 @@ async function executeLifecycleAction(profile, claim, exec = spawnLifecycleComma
2796
2830
  };
2797
2831
  }
2798
2832
  case "database-member-ready": {
2833
+ if (!profile.databaseUnit || !profile.databaseHealthUrl) {
2834
+ throw new Error("this workload has no controller-managed database lifecycle");
2835
+ }
2799
2836
  await requireSuccess(exec, ["/usr/bin/systemctl", "is-active", profile.databaseUnit], "database service check");
2800
2837
  const response = await fetcher(profile.databaseHealthUrl, { signal: AbortSignal.timeout(5000) });
2801
2838
  if (!response.ok && response.status !== 401)
@@ -2812,7 +2849,12 @@ async function executeLifecycleAction(profile, claim, exec = spawnLifecycleComma
2812
2849
  await requireSuccess(exec, ["/usr/bin/systemctl", "stop", ...profile.apiUnits], "API drain");
2813
2850
  return { sourceDrained: true };
2814
2851
  case "source-stopped":
2815
- await requireSuccess(exec, ["/usr/bin/systemctl", "stop", ...profile.apiUnits, profile.databaseUnit], "source retirement");
2852
+ await requireSuccess(exec, [
2853
+ "/usr/bin/systemctl",
2854
+ "stop",
2855
+ ...profile.apiUnits,
2856
+ ...profile.databaseUnit ? [profile.databaseUnit] : []
2857
+ ], "source retirement");
2816
2858
  return { sourceStopped: true };
2817
2859
  default:
2818
2860
  throw new Error("unknown lifecycle action");
@@ -2940,7 +2982,7 @@ function materializeWarpMdm(options) {
2940
2982
  }
2941
2983
 
2942
2984
  // src/version.ts
2943
- var VERSION = "0.1.24";
2985
+ var VERSION = "0.1.26";
2944
2986
 
2945
2987
  // src/index.ts
2946
2988
  function loadOrCreateSeed(path) {
@@ -3083,6 +3125,7 @@ if (import.meta.main) {
3083
3125
  nodeKey: nodeKey2,
3084
3126
  keys: keys2,
3085
3127
  label: process.env.FZ_NODE_LABEL,
3128
+ edgeHostname: process.env.FZ_NODE_HOSTNAME,
3086
3129
  gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync5(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined,
3087
3130
  privateNetworkAttachment: privateNetworkAttachmentFromEnvironment()
3088
3131
  });
@@ -3302,6 +3345,7 @@ if (import.meta.main) {
3302
3345
  nodeKey,
3303
3346
  keys,
3304
3347
  label: process.env.FZ_NODE_LABEL,
3348
+ edgeHostname: process.env.FZ_NODE_HOSTNAME,
3305
3349
  gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync5(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined,
3306
3350
  privateNetworkAttachment: privateNetworkAttachmentFromEnvironment()
3307
3351
  });
package/dist/fz.js CHANGED
@@ -163,6 +163,7 @@ var systemdPath = (value, label) => {
163
163
  throw new Error(`invalid ${label} path`);
164
164
  return value;
165
165
  };
166
+ var validNodeHostname = (value) => !value || value.length <= 253 && value === value.toLowerCase() && value.split(".").length >= 3 && value.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
166
167
  function warpConfigUnit(options) {
167
168
  if (!options.warpOrganization || !/^[a-z0-9][a-z0-9-]{0,62}$/i.test(options.warpOrganization)) {
168
169
  throw new Error("WARP organization is invalid");
@@ -252,10 +253,14 @@ function agentEnrolmentUnit(options) {
252
253
  if (!options.apiUrl || !options.enrolTokenCredentialPath || !options.enrolStatePath) {
253
254
  throw new Error("direct enrolment needs API, credential and state paths");
254
255
  }
256
+ if (!validNodeHostname(options.nodeHostname))
257
+ throw new Error("node hostname is invalid");
255
258
  const bin = options.binPath ?? "fz-agent";
256
259
  const user = options.user ?? "forgezero";
257
260
  const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
258
261
  const label = options.nodeLabel ? `Environment=FZ_NODE_LABEL=${options.nodeLabel}
262
+ ` : "";
263
+ const hostname = options.nodeHostname ? `Environment=FZ_NODE_HOSTNAME=${options.nodeHostname}
259
264
  ` : "";
260
265
  const gitPublicKey = options.gitPublicKeyPath ? `Environment=FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}
261
266
  ` : "";
@@ -287,7 +292,7 @@ Environment=FZ_SEED_CREDENTIAL=agent-seed
287
292
  Environment=FZ_ENROL_TOKEN_CREDENTIAL=enrol-token
288
293
  Environment=FZ_ENROL_STATE_FILE=${options.enrolStatePath}
289
294
  Environment=FZ_API=${options.apiUrl}
290
- ${label}${gitPublicKey}${networkAttachment}ExecStart=${bin} enrol
295
+ ${label}${hostname}${gitPublicKey}${networkAttachment}ExecStart=${bin} enrol
291
296
  # A '+' fixed command runs as root solely to remove the host-bound one-time
292
297
  # ciphertext. Tenant code and the agent never receive a privilege boundary.
293
298
  ExecStartPost=+/usr/bin/rm -f ${options.enrolTokenCredentialPath}
@@ -346,6 +351,8 @@ WantedBy=multi-user.target
346
351
  `;
347
352
  }
348
353
  function agentUnit(options) {
354
+ if (!validNodeHostname(options.nodeHostname))
355
+ throw new Error("node hostname is invalid");
349
356
  const bin = options.binPath ?? "fz-agent";
350
357
  const user = options.user ?? "forgezero";
351
358
  const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
@@ -403,6 +410,7 @@ function agentUnit(options) {
403
410
  options.environment ? `FZ_ENVIRONMENT=${options.environment}` : null,
404
411
  options.enrolStatePath ? `FZ_ENROL_STATE_FILE=${options.enrolStatePath}` : null,
405
412
  options.nodeLabel ? `FZ_NODE_LABEL=${options.nodeLabel}` : null,
413
+ options.nodeHostname ? `FZ_NODE_HOSTNAME=${options.nodeHostname}` : null,
406
414
  options.gitPublicKeyPath ? `FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}` : null,
407
415
  options.repository ? `FZ_DEPLOY_REPO=${options.repository}` : null,
408
416
  options.branch ? `FZ_DEPLOY_BRANCH=${options.branch}` : null,
@@ -966,7 +974,7 @@ async function resolveIdentity(selector, socketPath) {
966
974
  }
967
975
 
968
976
  // src/version.ts
969
- var VERSION = "0.1.24";
977
+ var VERSION = "0.1.26";
970
978
 
971
979
  // src/cli/index.ts
972
980
  var DEFAULT_MODE = THRESHOLD_MODES[0].id;
@@ -1205,6 +1213,7 @@ async function cmdAgent(options, args) {
1205
1213
  enrolTokenCredentialPath,
1206
1214
  enrolStatePath,
1207
1215
  nodeLabel: process.env.FZ_NODE_LABEL,
1216
+ nodeHostname: process.env.FZ_NODE_HOSTNAME,
1208
1217
  deployRoot: process.env.FZ_DEPLOY_ROOT ?? "/opt/forgezero"
1209
1218
  } : {},
1210
1219
  binPath: process.env.FZ_AGENT_BIN ?? "/usr/local/lib/forgezero/agent/fz-agent",
@@ -30,6 +30,8 @@ export interface GuestEnrolmentOptions {
30
30
  label?: string;
31
31
  gitDeployPublicKey?: string;
32
32
  privateNetworkAttachment?: GuestPrivateNetworkAttachment;
33
+ /** Stable API ingress identity; signed with the rest of the enrolment body. */
34
+ edgeHostname?: string;
33
35
  fetch?: (input: URL, init: RequestInit) => Promise<Response>;
34
36
  requestTimeoutMs?: number;
35
37
  }
@@ -135,6 +135,7 @@ async function enrolGuestIdentity(options) {
135
135
  label: options.label,
136
136
  gitDeployPublicKey: options.gitDeployPublicKey,
137
137
  privateNetworkAttachment: options.privateNetworkAttachment,
138
+ edgeHostname: options.edgeHostname?.trim() || undefined,
138
139
  publicKeys: {
139
140
  ed25519: options.keys.ed25519.publicKey,
140
141
  mlDsa: options.keys.mlDsa.publicKey
@@ -5,13 +5,13 @@ export interface LifecycleProfile {
5
5
  /** Root-owned systemd units whose normal stop path performs application drain. */
6
6
  apiUnits: readonly string[];
7
7
  /** Root-owned database service stopped only at the final retirement stage. */
8
- databaseUnit: string;
8
+ databaseUnit?: string;
9
9
  /** Loopback health endpoint for the API on this compute. */
10
10
  apiHealthUrl: string;
11
11
  /** Loopback health endpoint for the database member on this compute. */
12
- databaseHealthUrl: string;
12
+ databaseHealthUrl?: string;
13
13
  /** Ports that must be reachable on every signed controller-provided private peer. */
14
- databasePorts: readonly number[];
14
+ databasePorts?: readonly number[];
15
15
  }
16
16
  export interface LifecycleCommandResult {
17
17
  exitCode: number;
@@ -22,17 +22,23 @@ function validateLifecycleProfile(profile) {
22
22
  if (!Array.isArray(profile.apiUnits) || profile.apiUnits.length < 1 || profile.apiUnits.some((unit) => !unitPattern.test(unit))) {
23
23
  throw new Error("lifecycle profile needs one or more valid API service units");
24
24
  }
25
- if (!unitPattern.test(profile.databaseUnit))
25
+ const databaseValues = [profile.databaseUnit, profile.databaseHealthUrl, profile.databasePorts];
26
+ if (databaseValues.some((value) => value !== undefined) && databaseValues.some((value) => value === undefined)) {
27
+ throw new Error("database lifecycle settings must be supplied together");
28
+ }
29
+ if (profile.databaseUnit && !unitPattern.test(profile.databaseUnit))
26
30
  throw new Error("lifecycle profile database unit is invalid");
27
31
  const apiUrl = new URL(profile.apiHealthUrl);
28
32
  if (apiUrl.protocol !== "http:" || !["127.0.0.1", "[::1]", "::1", "localhost"].includes(apiUrl.hostname)) {
29
33
  throw new Error("API health URL must be loopback HTTP");
30
34
  }
31
- const databaseUrl = new URL(profile.databaseHealthUrl);
32
- if (databaseUrl.protocol !== "http:" || !(["127.0.0.1", "[::1]", "::1", "localhost"].includes(databaseUrl.hostname) || privateIp(databaseUrl.hostname))) {
33
- throw new Error("database health URL must be loopback or private HTTP");
35
+ if (profile.databaseHealthUrl) {
36
+ const databaseUrl = new URL(profile.databaseHealthUrl);
37
+ if (databaseUrl.protocol !== "http:" || !(["127.0.0.1", "[::1]", "::1", "localhost"].includes(databaseUrl.hostname) || privateIp(databaseUrl.hostname))) {
38
+ throw new Error("database health URL must be loopback or private HTTP");
39
+ }
34
40
  }
35
- if (!Array.isArray(profile.databasePorts) || profile.databasePorts.length < 1 || profile.databasePorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535)) {
41
+ if (profile.databasePorts && (!Array.isArray(profile.databasePorts) || profile.databasePorts.length < 1 || profile.databasePorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535))) {
36
42
  throw new Error("lifecycle profile database ports are invalid");
37
43
  }
38
44
  }
@@ -91,7 +97,7 @@ async function executeLifecycleAction(profile, claim, exec = spawnLifecycleComma
91
97
  throw new Error("WARP is not connected");
92
98
  }
93
99
  for (const address of claim.peerPrivateAddresses) {
94
- for (const port of profile.databasePorts)
100
+ for (const port of profile.databasePorts ?? [])
95
101
  await tcpProbe(address, port);
96
102
  }
97
103
  return {
@@ -101,6 +107,9 @@ async function executeLifecycleAction(profile, claim, exec = spawnLifecycleComma
101
107
  };
102
108
  }
103
109
  case "database-member-ready": {
110
+ if (!profile.databaseUnit || !profile.databaseHealthUrl) {
111
+ throw new Error("this workload has no controller-managed database lifecycle");
112
+ }
104
113
  await requireSuccess(exec, ["/usr/bin/systemctl", "is-active", profile.databaseUnit], "database service check");
105
114
  const response = await fetcher(profile.databaseHealthUrl, { signal: AbortSignal.timeout(5000) });
106
115
  if (!response.ok && response.status !== 401)
@@ -117,7 +126,12 @@ async function executeLifecycleAction(profile, claim, exec = spawnLifecycleComma
117
126
  await requireSuccess(exec, ["/usr/bin/systemctl", "stop", ...profile.apiUnits], "API drain");
118
127
  return { sourceDrained: true };
119
128
  case "source-stopped":
120
- await requireSuccess(exec, ["/usr/bin/systemctl", "stop", ...profile.apiUnits, profile.databaseUnit], "source retirement");
129
+ await requireSuccess(exec, [
130
+ "/usr/bin/systemctl",
131
+ "stop",
132
+ ...profile.apiUnits,
133
+ ...profile.databaseUnit ? [profile.databaseUnit] : []
134
+ ], "source retirement");
121
135
  return { sourceStopped: true };
122
136
  default:
123
137
  throw new Error("unknown lifecycle action");
@@ -1,5 +1,6 @@
1
1
  import type { NodeKeyPair } from '@forgezero/runtime/identity';
2
2
  export type MigrationNetwork = 'private-lan' | 'cloudflare-warp';
3
+ export type MigrationEvidenceProfile = 'forgezero-platform' | 'tenant-managed';
3
4
  export type MigrationAction = 'network-ready' | 'database-member-ready' | 'api-ready' | 'source-drained' | 'source-stopped';
4
5
  export interface MigrationEvidence {
5
6
  targetAgentReady?: boolean;
@@ -14,6 +15,7 @@ export interface RemoteMigrationClaim {
14
15
  migrationKey: string;
15
16
  action: MigrationAction;
16
17
  network: MigrationNetwork;
18
+ evidenceProfile?: MigrationEvidenceProfile;
17
19
  claimToken: string;
18
20
  claimExpiresAtTs: number;
19
21
  attempt: number;
@@ -10,30 +10,44 @@ class CacheError extends Error {
10
10
  var DEFAULT_TTL_MS = 60000;
11
11
  var DEFAULT_MAX_STALE_MS = 300000;
12
12
  function createSecretCache(options) {
13
- const entries = new Map;
13
+ let entries = new Map;
14
14
  const now = options.now ?? (() => Date.now());
15
15
  const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
16
16
  const maxStaleMs = options.maxStaleMs ?? DEFAULT_MAX_STALE_MS;
17
17
  let cursor = 0;
18
18
  let replicated = false;
19
19
  let lastSyncOkMs = now();
20
- const loadScope = async () => {
20
+ const fetchScope = async () => {
21
+ const next = new Map;
21
22
  if (!options.list)
22
- return { loaded: 0, failed: [] };
23
+ return { entries: next, loaded: 0, failed: [] };
23
24
  const names = await options.list();
24
25
  const failed = [];
25
- let loaded = 0;
26
26
  for (const name of names) {
27
27
  try {
28
28
  const result = await options.fetch(name);
29
- entries.set(name, { value: result.value, version: result.version, fetchedAtMs: now() });
30
- loaded += 1;
29
+ next.set(name, { value: result.value, version: result.version, fetchedAtMs: now() });
31
30
  } catch {
32
31
  failed.push(name);
33
32
  }
34
33
  }
34
+ return { entries: next, loaded: next.size, failed };
35
+ };
36
+ const loadScope = async () => {
37
+ if (!options.list)
38
+ return { loaded: 0, failed: [] };
39
+ const snapshot = await fetchScope();
40
+ entries = snapshot.entries;
41
+ replicated = snapshot.failed.length === 0;
42
+ return { loaded: snapshot.loaded, failed: snapshot.failed };
43
+ };
44
+ const refreshScope = async () => {
45
+ const snapshot = await fetchScope();
46
+ if (snapshot.failed.length > 0) {
47
+ throw new CacheError("FETCH_FAILED", `Could not refresh ${snapshot.failed.length} assigned vault ${snapshot.failed.length === 1 ? "entry" : "entries"}.`);
48
+ }
49
+ entries = snapshot.entries;
35
50
  replicated = true;
36
- return { loaded, failed };
37
51
  };
38
52
  return {
39
53
  names: () => [...entries.keys()],
@@ -67,13 +81,26 @@ function createSecretCache(options) {
67
81
  const result = await options.changes(cursor);
68
82
  if (result.resync) {
69
83
  const dropped = [...entries.keys()];
70
- entries.clear();
84
+ if (options.list) {
85
+ await refreshScope();
86
+ } else {
87
+ entries.clear();
88
+ }
71
89
  cursor = 0;
72
90
  lastSyncOkMs = now();
73
- if (options.list)
74
- await loadScope();
75
91
  return { invalidated: dropped, cursor: 0, resync: true };
76
92
  }
93
+ if (options.list && result.changed.length > 0) {
94
+ const held = new Set(entries.keys());
95
+ await refreshScope();
96
+ cursor = result.version;
97
+ lastSyncOkMs = now();
98
+ return {
99
+ invalidated: result.changed.filter((name) => held.has(name) || entries.has(name)),
100
+ cursor,
101
+ resync: false
102
+ };
103
+ }
77
104
  const invalidated = [];
78
105
  for (const name of result.changed) {
79
106
  if (entries.delete(name))
@@ -119,6 +119,8 @@ export interface UnitOptions {
119
119
  enrolTokenCredentialPath?: string;
120
120
  enrolStatePath?: string;
121
121
  nodeLabel?: string;
122
+ /** Stable API ingress identity bound by the signed one-time enrolment. */
123
+ nodeHostname?: string;
122
124
  }
123
125
  export declare const DEPLOYMENT_RUNNER_USER = "forgezero-runner";
124
126
  export declare const DEPLOYMENT_GROUP = "forgezero-deploy";
package/dist/provision.js CHANGED
@@ -55,6 +55,7 @@ var systemdPath = (value, label) => {
55
55
  throw new Error(`invalid ${label} path`);
56
56
  return value;
57
57
  };
58
+ var validNodeHostname = (value) => !value || value.length <= 253 && value === value.toLowerCase() && value.split(".").length >= 3 && value.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
58
59
  function warpConfigUnit(options) {
59
60
  if (!options.warpOrganization || !/^[a-z0-9][a-z0-9-]{0,62}$/i.test(options.warpOrganization)) {
60
61
  throw new Error("WARP organization is invalid");
@@ -144,10 +145,14 @@ function agentEnrolmentUnit(options) {
144
145
  if (!options.apiUrl || !options.enrolTokenCredentialPath || !options.enrolStatePath) {
145
146
  throw new Error("direct enrolment needs API, credential and state paths");
146
147
  }
148
+ if (!validNodeHostname(options.nodeHostname))
149
+ throw new Error("node hostname is invalid");
147
150
  const bin = options.binPath ?? "fz-agent";
148
151
  const user = options.user ?? "forgezero";
149
152
  const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
150
153
  const label = options.nodeLabel ? `Environment=FZ_NODE_LABEL=${options.nodeLabel}
154
+ ` : "";
155
+ const hostname = options.nodeHostname ? `Environment=FZ_NODE_HOSTNAME=${options.nodeHostname}
151
156
  ` : "";
152
157
  const gitPublicKey = options.gitPublicKeyPath ? `Environment=FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}
153
158
  ` : "";
@@ -179,7 +184,7 @@ Environment=FZ_SEED_CREDENTIAL=agent-seed
179
184
  Environment=FZ_ENROL_TOKEN_CREDENTIAL=enrol-token
180
185
  Environment=FZ_ENROL_STATE_FILE=${options.enrolStatePath}
181
186
  Environment=FZ_API=${options.apiUrl}
182
- ${label}${gitPublicKey}${networkAttachment}ExecStart=${bin} enrol
187
+ ${label}${hostname}${gitPublicKey}${networkAttachment}ExecStart=${bin} enrol
183
188
  # A '+' fixed command runs as root solely to remove the host-bound one-time
184
189
  # ciphertext. Tenant code and the agent never receive a privilege boundary.
185
190
  ExecStartPost=+/usr/bin/rm -f ${options.enrolTokenCredentialPath}
@@ -238,6 +243,8 @@ WantedBy=multi-user.target
238
243
  `;
239
244
  }
240
245
  function agentUnit(options) {
246
+ if (!validNodeHostname(options.nodeHostname))
247
+ throw new Error("node hostname is invalid");
241
248
  const bin = options.binPath ?? "fz-agent";
242
249
  const user = options.user ?? "forgezero";
243
250
  const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
@@ -295,6 +302,7 @@ function agentUnit(options) {
295
302
  options.environment ? `FZ_ENVIRONMENT=${options.environment}` : null,
296
303
  options.enrolStatePath ? `FZ_ENROL_STATE_FILE=${options.enrolStatePath}` : null,
297
304
  options.nodeLabel ? `FZ_NODE_LABEL=${options.nodeLabel}` : null,
305
+ options.nodeHostname ? `FZ_NODE_HOSTNAME=${options.nodeHostname}` : null,
298
306
  options.gitPublicKeyPath ? `FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}` : null,
299
307
  options.repository ? `FZ_DEPLOY_REPO=${options.repository}` : null,
300
308
  options.branch ? `FZ_DEPLOY_BRANCH=${options.branch}` : null,
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** One package version shared by both public binaries. Pinned to package.json by tests. */
2
- export declare const VERSION = "0.1.24";
2
+ export declare const VERSION = "0.1.26";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "Publishing happens from an operator's machine, not CI \u2014 CLAUDE.md records that the absence of CI is deliberate. npm's `provenance` attests a tarball was built by a recognised CI provider from a named commit, so it cannot be produced here: it was set, and the first publish failed with `Automatic provenance generation not supported for provider: null`. A setting that can never be satisfied is worse than none, because it reads as a guarantee nobody is getting. Restore it the day this publishes from CI, and not before.",
3
3
  "name": "@forgezero/agent",
4
- "version": "0.1.24",
4
+ "version": "0.1.26",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "check": "tsc --noEmit",