@devfn/config 0.1.0 → 0.1.1

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.
@@ -0,0 +1,5 @@
1
+ /** Credential names are aliases delimited by separators or case boundaries,
2
+ * or documented compact qualifier/alias pairs. */
3
+ export declare function isCredentialKey(name: string): boolean;
4
+ /** Reject keys that would alias on case-insensitive process environments. */
5
+ export declare function assertEnvironmentKeyCasing(...groups: ReadonlyArray<Iterable<string>>): void;
@@ -0,0 +1,71 @@
1
+ /** Shared grammar for environment keys, argv flags and URL parameters. */
2
+ const CREDENTIAL_ALIASES = new Set([
3
+ "accesskey", "accesskeyid", "accesstoken", "apikey", "apikeys", "auth", "authkey", "authorization", "authtoken",
4
+ "bearer", "clientsecret", "cookie", "cred", "credential", "credentials", "creds", "key", "passwd", "password",
5
+ "pass", "passcode", "passkey", "passphrase", "privatekey", "pwd", "refreshtoken", "secret", "secretkey", "sessionid", "sessiontoken",
6
+ "sig", "signature", "token", "pat", "phpsessid", "xamzcredential", "xamzsignature", "xgoogcredential", "xgoogsignature",
7
+ ]);
8
+ // Compact names have no word boundary. Support known qualifier + alias pairs;
9
+ // otherwise words such as monkey and compass would be false positives.
10
+ const COMPACT_QUALIFIERS = new Set([
11
+ "api", "app", "auth", "aws", "client", "db", "database", "google", "oauth", "pg", "server", "service", "session", "user", "xamz", "xgoog",
12
+ ]);
13
+ // These suffixes identify credentials even with an application-specific
14
+ // qualifier that DevFn cannot enumerate (for example GITHUBTOKEN).
15
+ // Short ambiguous suffixes such as key, pass and sig still require a known
16
+ // qualifier or a word boundary, so MONKEY and COMPASS remain ordinary names.
17
+ const UNAMBIGUOUS_SUFFIXES = [
18
+ "password", "passwd", "passphrase", "passcode", "passkey", "secret", "token", "credential", "credentials",
19
+ "apikey", "apikeys", "accesskey",
20
+ "secretkey", "accesskeyid",
21
+ "privatekey", "sessionid", "sessiontoken", "authorization",
22
+ ];
23
+ function words(name) {
24
+ // Numbered copies of a credential field retain the field's meaning.
25
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
26
+ .replace(/([A-Z])([A-Z][a-z])/g, "$1 $2")
27
+ .split(/[^A-Za-z0-9]+/).map((word) => {
28
+ let end = word.length;
29
+ while (end > 0 && (word.codePointAt(end - 1) ?? 0) >= 48 && (word.codePointAt(end - 1) ?? 0) <= 57)
30
+ end -= 1;
31
+ return word.slice(0, end).toLowerCase();
32
+ }).filter(Boolean);
33
+ }
34
+ function compactCredential(name, qualifiers = 0) {
35
+ if (qualifiers > 0 && CREDENTIAL_ALIASES.has(name))
36
+ return true;
37
+ if (qualifiers === 3)
38
+ return false;
39
+ for (const qualifier of COMPACT_QUALIFIERS) {
40
+ if (name.startsWith(qualifier) && name.length > qualifier.length && compactCredential(name.slice(qualifier.length), qualifiers + 1))
41
+ return true;
42
+ if (name.endsWith(qualifier) && name.length > qualifier.length && compactCredential(name.slice(0, -qualifier.length), qualifiers + 1))
43
+ return true;
44
+ }
45
+ return false;
46
+ }
47
+ /** Credential names are aliases delimited by separators or case boundaries,
48
+ * or documented compact qualifier/alias pairs. */
49
+ export function isCredentialKey(name) {
50
+ const parts = words(name);
51
+ for (let start = 0; start < parts.length; start += 1) {
52
+ for (let end = start + 1; end <= Math.min(parts.length, start + 3); end += 1) {
53
+ if (CREDENTIAL_ALIASES.has(parts.slice(start, end).join("")))
54
+ return true;
55
+ }
56
+ }
57
+ const compact = parts.join("");
58
+ return compactCredential(compact) || UNAMBIGUOUS_SUFFIXES.some((suffix) => compact.length > suffix.length && compact.endsWith(suffix));
59
+ }
60
+ /** Reject keys that would alias on case-insensitive process environments. */
61
+ export function assertEnvironmentKeyCasing(...groups) {
62
+ const seen = new Map();
63
+ for (const group of groups)
64
+ for (const key of group) {
65
+ const folded = key.toUpperCase();
66
+ const previous = seen.get(folded);
67
+ if (previous && previous !== key)
68
+ throw new Error(`Environment key ${key} collides with ${previous} after case folding.`);
69
+ seen.set(folded, key);
70
+ }
71
+ }
package/dist/index.d.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  export { DevFnConfigError, type ConfigErrorCode } from "./errors.js";
2
+ export { isCredentialKey, assertEnvironmentKeyCasing } from "./credentials.js";
2
3
  export { discoverProject, renderDevFnConfig } from "./discovery.js";
3
4
  export { DEVFN_CONFIG_CANDIDATES, findDevFnRoot, loadDevFnConfig, loadDevFnPolicy, loadTrustedDevFnConfig, resolveDevFnManifestPath } from "./load.js";
4
5
  export { resolveContainedPath } from "./paths.js";
5
- export { matchesProcessIdentity, processBirthSignature, processExists } from "./process-identity.js";
6
- export { validateDevFnConfig, validateDevFnPolicy } from "./schema.js";
6
+ export { classifyLegacyDarwinIdentity, classifyProcessIdentity, matchesProcessIdentity, processBirthSignature, processExists, processGroupStatus, processIdentityStatus, startedProcessBirthSignature, type ProcessGroupStatus, type ProcessIdentityStatus } from "./process-identity.js";
7
+ export { resourceEnvironmentKeys, resourceToken, validateDevFnConfig, validateDevFnPolicy } from "./schema.js";
7
8
  export { configDigest, defaultStateDir, isProjectTrusted, readTrustedManifest, trustProject } from "./trust.js";
8
9
  export type * from "./types.js";
9
10
  export declare function defineDevFnConfig<T extends import("./types.js").DevFnConfig>(config: T): T;
package/dist/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  export { DevFnConfigError } from "./errors.js";
2
+ export { isCredentialKey, assertEnvironmentKeyCasing } from "./credentials.js";
2
3
  export { discoverProject, renderDevFnConfig } from "./discovery.js";
3
4
  export { DEVFN_CONFIG_CANDIDATES, findDevFnRoot, loadDevFnConfig, loadDevFnPolicy, loadTrustedDevFnConfig, resolveDevFnManifestPath } from "./load.js";
4
5
  export { resolveContainedPath } from "./paths.js";
5
- export { matchesProcessIdentity, processBirthSignature, processExists } from "./process-identity.js";
6
- export { validateDevFnConfig, validateDevFnPolicy } from "./schema.js";
6
+ export { classifyLegacyDarwinIdentity, classifyProcessIdentity, matchesProcessIdentity, processBirthSignature, processExists, processGroupStatus, processIdentityStatus, startedProcessBirthSignature } from "./process-identity.js";
7
+ export { resourceEnvironmentKeys, resourceToken, validateDevFnConfig, validateDevFnPolicy } from "./schema.js";
7
8
  export { configDigest, defaultStateDir, isProjectTrusted, readTrustedManifest, trustProject } from "./trust.js";
8
9
  export function defineDevFnConfig(config) { return config; }
9
10
  export function defineDevFnPolicy(policy) { return policy; }
@@ -1,3 +1,48 @@
1
1
  export declare function processBirthSignature(pid: number): Promise<string | undefined>;
2
+ /**
3
+ * The birth signature of a process just started, read up to ten times 20 ms
4
+ * apart: a single ps or /proc read may miss it transiently. Undefined only
5
+ * when every read failed.
6
+ */
7
+ export declare function startedProcessBirthSignature(pid: number): Promise<string | undefined>;
8
+ /** Whether the PID is a live process; a zombie is not one. */
2
9
  export declare function processExists(pid: number): boolean;
3
- export declare function matchesProcessIdentity(pid: number, signature?: string): Promise<boolean>;
10
+ export type ProcessIdentityStatus = "running" | "exited" | "identity-mismatch" | "unverified";
11
+ /**
12
+ * Only an absent PID or a readable birth signature that differs proves a
13
+ * recorded process is gone. A live PID whose recorded or current signature
14
+ * is unknown may still be that process.
15
+ */
16
+ export declare function classifyProcessIdentity(exists: boolean, recorded?: string, current?: string): ProcessIdentityStatus;
17
+ /**
18
+ * Judge a live PID against a legacy darwin: signature. Earlier releases
19
+ * rendered its start time in the recording caller's time zone and locale,
20
+ * so neither an equal nor a different reading proves anything here. The
21
+ * record's own UTC time decides instead: DevFn writes it only after reading
22
+ * the signature of a live owner, so a process that started after it is a
23
+ * different process reusing the PID, and one that started no later was
24
+ * alive under that PID when the record was written and so is its owner.
25
+ * Without a record time, or within the clock tolerance, nothing is proven.
26
+ */
27
+ export declare function classifyLegacyDarwinIdentity(pinnedStartTime: string | undefined, recordedAt: string | undefined): ProcessIdentityStatus;
28
+ /**
29
+ * recordedAt is the UTC time the identity was recorded, after it was read
30
+ * from the live process; it decides only legacy darwin: signatures.
31
+ */
32
+ export declare function processIdentityStatus(pid: number, signature?: string, recordedAt?: string): Promise<ProcessIdentityStatus>;
33
+ export type ProcessGroupStatus = "running" | "unverified" | "exited" | "identity-mismatch";
34
+ /**
35
+ * Whether anything a recorded process group leader ran may still run. On
36
+ * POSIX the leader's PID is its group ID, and a PID is not reused while a
37
+ * group with that ID exists, so:
38
+ * - a reused PID ("identity-mismatch") proves the original group gone;
39
+ * - an exited leader whose group still has members is "unverified": those
40
+ * members are what it started, unless the group was emptied and a later
41
+ * process reusing the PID started another, which DevFn cannot tell apart;
42
+ * - only "exited" (leader and group gone, or only zombies left) and
43
+ * "identity-mismatch" are death evidence. Windows has no process groups;
44
+ * only the leader is judged.
45
+ */
46
+ export declare function processGroupStatus(pid: number, signature?: string, recordedAt?: string): Promise<ProcessGroupStatus>;
47
+ /** Whether the PID is verifiably the recorded process, as required before signalling it. */
48
+ export declare function matchesProcessIdentity(pid: number, signature?: string, recordedAt?: string): Promise<boolean>;
@@ -1,21 +1,39 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { readdir, readFile } from "node:fs/promises";
2
4
  import { promisify } from "node:util";
3
5
  const execFileAsync = promisify(execFile);
6
+ async function darwinStartTime(pid, env) {
7
+ const { stdout } = await execFileAsync("ps", ["-o", "lstart=", "-p", String(pid)], { env });
8
+ return stdout.trim() || undefined;
9
+ }
10
+ // ps renders the start time in the caller's time zone and locale; pin both
11
+ // so every DevFn invocation reads the same process the same way.
12
+ const PINNED_PS_ENVIRONMENT = { TZ: "UTC0", LC_ALL: "C" };
13
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
14
+ /** The start second, in epoch milliseconds, of a ps lstart reading taken in the pinned environment. */
15
+ function parsePinnedStartTime(text) {
16
+ const match = /^[A-Z][a-z]{2} ([A-Z][a-z]{2}) +(\d{1,2}) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/.exec(text);
17
+ const month = match ? MONTHS.indexOf(match[1]) : -1;
18
+ if (!match || month < 0)
19
+ return undefined;
20
+ const [, , day, hours, minutes, seconds, year] = match.map(Number);
21
+ return Date.UTC(year, month, day, hours, minutes, seconds);
22
+ }
4
23
  export async function processBirthSignature(pid) {
5
24
  try {
6
25
  if (process.platform === "linux") {
7
- const stat = await import("node:fs/promises").then((fs) => fs.readFile(`/proc/${pid}/stat`, "utf8"));
8
- const afterCommand = stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/);
9
- const startTime = afterCommand[19];
26
+ const startTime = linuxStatFields(await readFile(`/proc/${pid}/stat`, "utf8"))[19];
10
27
  return startTime ? `linux:${startTime}` : undefined;
11
28
  }
12
29
  if (process.platform === "darwin") {
13
- const { stdout } = await execFileAsync("ps", ["-o", "lstart=", "-p", String(pid)]);
14
- return `darwin:${stdout.trim()}`;
30
+ const startTime = await darwinStartTime(pid, { ...process.env, ...PINNED_PS_ENVIRONMENT });
31
+ return startTime ? `darwin-utc:${startTime}` : undefined;
15
32
  }
16
33
  if (process.platform === "win32") {
17
34
  const { stdout } = await execFileAsync("powershell", ["-NoProfile", "-Command", `(Get-Process -Id ${pid}).StartTime.ToUniversalTime().ToString('O')`]);
18
- return `win32:${stdout.trim()}`;
35
+ const startTime = stdout.trim();
36
+ return startTime ? `win32:${startTime}` : undefined;
19
37
  }
20
38
  }
21
39
  catch {
@@ -23,19 +41,210 @@ export async function processBirthSignature(pid) {
23
41
  }
24
42
  return undefined;
25
43
  }
44
+ /**
45
+ * The birth signature of a process just started, read up to ten times 20 ms
46
+ * apart: a single ps or /proc read may miss it transiently. Undefined only
47
+ * when every read failed.
48
+ */
49
+ export async function startedProcessBirthSignature(pid) {
50
+ for (let attempt = 0; attempt < 10; attempt += 1) {
51
+ if (attempt > 0)
52
+ await new Promise((resolve) => setTimeout(resolve, 20));
53
+ const signature = await processBirthSignature(pid);
54
+ if (signature)
55
+ return signature;
56
+ }
57
+ return undefined;
58
+ }
59
+ /** The /proc/<pid>/stat fields after the command name: state, ppid, pgrp, ... */
60
+ function linuxStatFields(stat) {
61
+ return stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/);
62
+ }
63
+ // A zombie has exited but keeps its PID and group ID until its parent reaps
64
+ // it, which a container PID 1 that does not reap never does. It runs nothing,
65
+ // and neither ID can be reused meanwhile, so it counts as exited.
66
+ const EXITED_STATE = /^[ZX]/;
67
+ /** Whether the PID is a live process; a zombie is not one. */
26
68
  export function processExists(pid) {
69
+ let signalled = true;
27
70
  try {
28
71
  process.kill(pid, 0);
72
+ }
73
+ catch (error) {
74
+ if (error.code !== "EPERM")
75
+ return false;
76
+ signalled = false;
77
+ }
78
+ if (process.platform !== "linux")
29
79
  return true;
80
+ try {
81
+ return !EXITED_STATE.test(linuxStatFields(readFileSync(`/proc/${pid}/stat`, "utf8"))[0] ?? "");
30
82
  }
31
83
  catch (error) {
32
- return error.code === "EPERM";
84
+ if (!signalled || error.code !== "ENOENT")
85
+ return true;
86
+ // Reaped meanwhile, unless /proc hides a process we may signal (hidepid).
87
+ try {
88
+ process.kill(pid, 0);
89
+ return true;
90
+ }
91
+ catch (signalError) {
92
+ return signalError.code !== "ESRCH";
93
+ }
33
94
  }
34
95
  }
35
- export async function matchesProcessIdentity(pid, signature) {
36
- if (!processExists(pid))
96
+ /** Whether a PID signal 0 reaches is a darwin zombie; elsewhere processExists judges. */
97
+ async function darwinZombie(pid) {
98
+ if (process.platform !== "darwin")
37
99
  return false;
38
- if (!signature)
39
- return false;
40
- return await processBirthSignature(pid) === signature;
100
+ const { stdout } = await execFileAsync("ps", ["-o", "stat=", "-p", String(pid)]).catch(() => ({ stdout: "" }));
101
+ return EXITED_STATE.test(stdout.trim());
102
+ }
103
+ /** Whether /proc hides processes of other users (hidepid), so a scan may miss group members. */
104
+ async function procHidesProcesses() {
105
+ const mounts = await readFile("/proc/self/mountinfo", "utf8");
106
+ return mounts.split("\n").some((line) => {
107
+ const [mount, filesystem] = line.split(" - ");
108
+ if (mount?.split(" ")[4] !== "/proc" || !filesystem)
109
+ return false;
110
+ return (filesystem.split(" ")[2] ?? "").split(",").some((option) => option.startsWith("hidepid=") && !["hidepid=0", "hidepid=off"].includes(option));
111
+ });
112
+ }
113
+ /**
114
+ * States of every member of a process group, or undefined when they cannot
115
+ * all be listed: an entry that cannot be read or parsed, or a /proc that
116
+ * hides processes, may be a live member.
117
+ */
118
+ async function processGroupMemberStates(pgid) {
119
+ try {
120
+ if (process.platform === "linux") {
121
+ if (await procHidesProcesses())
122
+ return undefined;
123
+ const states = [];
124
+ for (const entry of await readdir("/proc")) {
125
+ if (!/^\d+$/.test(entry))
126
+ continue;
127
+ const stat = await readFile(`/proc/${entry}/stat`, "utf8").catch((error) => {
128
+ // Only a process that ended meanwhile may be skipped.
129
+ if (error.code === "ENOENT")
130
+ return undefined;
131
+ throw error;
132
+ });
133
+ if (stat === undefined)
134
+ continue;
135
+ const [state, , group] = linuxStatFields(stat);
136
+ if (!stat.includes(")") || !/^[A-Za-z]$/.test(state ?? "") || !/^\d+$/.test(group ?? ""))
137
+ return undefined;
138
+ if (Number(group) === pgid)
139
+ states.push(state);
140
+ }
141
+ return states;
142
+ }
143
+ if (process.platform === "darwin") {
144
+ const { stdout } = await execFileAsync("ps", ["-A", "-o", "pgid=,stat="]);
145
+ const rows = stdout.split("\n").filter((line) => line.trim()).map((line) => /^\s*(\d+)\s+(\S+)\s*$/.exec(line));
146
+ if (rows.some((row) => !row))
147
+ return undefined;
148
+ return rows.filter((row) => Number(row[1]) === pgid).map((row) => row[2]);
149
+ }
150
+ }
151
+ catch {
152
+ return undefined;
153
+ }
154
+ return undefined;
155
+ }
156
+ /**
157
+ * Only an absent PID or a readable birth signature that differs proves a
158
+ * recorded process is gone. A live PID whose recorded or current signature
159
+ * is unknown may still be that process.
160
+ */
161
+ export function classifyProcessIdentity(exists, recorded, current) {
162
+ if (!exists)
163
+ return "exited";
164
+ if (!recorded || !current)
165
+ return "unverified";
166
+ if (current === recorded)
167
+ return "running";
168
+ // Signatures read in different formats cannot be compared.
169
+ return signatureFormat(current) === signatureFormat(recorded) ? "identity-mismatch" : "unverified";
170
+ }
171
+ function signatureFormat(signature) {
172
+ return signature.includes(":") ? signature.slice(0, signature.indexOf(":")) : "";
173
+ }
174
+ /** Allowance for wall-clock adjustment between a process start and its record. */
175
+ const RECORD_CLOCK_TOLERANCE_MS = 2_000;
176
+ /**
177
+ * Judge a live PID against a legacy darwin: signature. Earlier releases
178
+ * rendered its start time in the recording caller's time zone and locale,
179
+ * so neither an equal nor a different reading proves anything here. The
180
+ * record's own UTC time decides instead: DevFn writes it only after reading
181
+ * the signature of a live owner, so a process that started after it is a
182
+ * different process reusing the PID, and one that started no later was
183
+ * alive under that PID when the record was written and so is its owner.
184
+ * Without a record time, or within the clock tolerance, nothing is proven.
185
+ */
186
+ export function classifyLegacyDarwinIdentity(pinnedStartTime, recordedAt) {
187
+ const start = pinnedStartTime === undefined ? undefined : parsePinnedStartTime(pinnedStartTime);
188
+ const recorded = typeof recordedAt === "string" ? Date.parse(recordedAt) : Number.NaN;
189
+ if (start === undefined || !Number.isFinite(recorded))
190
+ return "unverified";
191
+ if (start > recorded + RECORD_CLOCK_TOLERANCE_MS)
192
+ return "identity-mismatch";
193
+ return start <= recorded ? "running" : "unverified";
194
+ }
195
+ /**
196
+ * recordedAt is the UTC time the identity was recorded, after it was read
197
+ * from the live process; it decides only legacy darwin: signatures.
198
+ */
199
+ export async function processIdentityStatus(pid, signature, recordedAt) {
200
+ if (!processExists(pid) || await darwinZombie(pid))
201
+ return "exited";
202
+ if (signature && process.platform === "darwin" && signatureFormat(signature) === "darwin") {
203
+ const start = await darwinStartTime(pid, { ...process.env, ...PINNED_PS_ENVIRONMENT }).catch(() => undefined);
204
+ return processExists(pid) ? classifyLegacyDarwinIdentity(start, recordedAt) : "exited";
205
+ }
206
+ const current = signature ? await processBirthSignature(pid) : undefined;
207
+ // A signature that cannot be read from a process that exited meanwhile is
208
+ // not an unverified owner.
209
+ if (signature && current === undefined && !processExists(pid))
210
+ return "exited";
211
+ return classifyProcessIdentity(true, signature, current);
212
+ }
213
+ /**
214
+ * Whether anything a recorded process group leader ran may still run. On
215
+ * POSIX the leader's PID is its group ID, and a PID is not reused while a
216
+ * group with that ID exists, so:
217
+ * - a reused PID ("identity-mismatch") proves the original group gone;
218
+ * - an exited leader whose group still has members is "unverified": those
219
+ * members are what it started, unless the group was emptied and a later
220
+ * process reusing the PID started another, which DevFn cannot tell apart;
221
+ * - only "exited" (leader and group gone, or only zombies left) and
222
+ * "identity-mismatch" are death evidence. Windows has no process groups;
223
+ * only the leader is judged.
224
+ */
225
+ export async function processGroupStatus(pid, signature, recordedAt) {
226
+ const leader = await processIdentityStatus(pid, signature, recordedAt);
227
+ if (leader !== "exited" || process.platform === "win32")
228
+ return leader;
229
+ try {
230
+ process.kill(-pid, 0);
231
+ }
232
+ catch (error) {
233
+ const code = error.code;
234
+ if (code === "ESRCH")
235
+ return "exited";
236
+ // macOS refuses to signal a group of only zombies; elsewhere a refusal
237
+ // means a member DevFn may not signal, which may be live.
238
+ if (code !== "EPERM" || process.platform !== "darwin")
239
+ return "unverified";
240
+ }
241
+ // Signal 0 also reaches zombies. The group is gone only when members are
242
+ // listed and every one has exited; one that cannot be listed may be live.
243
+ // Members reaped after the signal leave none to list, and so are gone too.
244
+ const states = await processGroupMemberStates(pid);
245
+ return states && states.every((state) => EXITED_STATE.test(state)) ? "exited" : "unverified";
246
+ }
247
+ /** Whether the PID is verifiably the recorded process, as required before signalling it. */
248
+ export async function matchesProcessIdentity(pid, signature, recordedAt) {
249
+ return await processIdentityStatus(pid, signature, recordedAt) === "running";
41
250
  }
package/dist/schema.d.ts CHANGED
@@ -1,3 +1,7 @@
1
- import type { DevFnConfig, DevFnPolicy } from "./types.js";
1
+ import type { DevFnConfig, DevFnPolicy, ResourceSpec } from "./types.js";
2
+ /** Normalized environment token of a resource or member name. */
3
+ export declare function resourceToken(name: string): string;
4
+ /** Every generated key a resource can emit, whichever target a profile selects. */
5
+ export declare function resourceEnvironmentKeys(name: string, spec: ResourceSpec): string[];
2
6
  export declare function validateDevFnConfig(value: unknown): DevFnConfig;
3
7
  export declare function validateDevFnPolicy(value: unknown): DevFnPolicy;
package/dist/schema.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { DevFnConfigError } from "./errors.js";
3
- const SENSITIVE_KEY = /(authorization|token|secret|password|cookie|api[-_]?key|session[-_]?id|access[-_]?key|refresh[-_]?token)/i;
3
+ import { isCredentialKey } from "./credentials.js";
4
4
  function fail(message, field) {
5
5
  throw new DevFnConfigError("DEVFN_CONFIG_INVALID", message, field);
6
6
  }
@@ -31,10 +31,25 @@ function stringArray(value, field) {
31
31
  fail(`${field} must be an array.`, field);
32
32
  return value.map((item, index) => string(item, `${field}[${index}]`));
33
33
  }
34
- function stringMap(value, field) {
34
+ function stringMap(value, field, allowHost = false) {
35
35
  if (value === undefined)
36
36
  return undefined;
37
- return Object.fromEntries(Object.entries(record(value, field)).map(([key, item]) => [key, string(item, `${field}.${key}`)]));
37
+ const seen = new Set();
38
+ return Object.fromEntries(Object.entries(record(value, field)).map(([key, item]) => {
39
+ environmentKey(key, `${field}.${key}`, allowHost);
40
+ if (seen.has(key.toUpperCase()))
41
+ fail(`${field} has colliding environment keys for ${key}.`, `${field}.${key}`);
42
+ seen.add(key.toUpperCase());
43
+ return [key, string(item, `${field}.${key}`)];
44
+ }));
45
+ }
46
+ function environmentKey(key, field, allowHost = false) {
47
+ if (!/^[A-Za-z_]\w*$/.test(key))
48
+ fail(`${field} must be an environment variable name.`, field);
49
+ if (["__proto__", "constructor", "prototype"].includes(key))
50
+ fail(`${field} is not a supported environment key.`, field);
51
+ if (key.toUpperCase().startsWith("DEVFN_") || (!allowHost && key.toUpperCase() === "HOST"))
52
+ fail(`${field} is reserved for DevFn startup.`, field);
38
53
  }
39
54
  function integer(value, field, min = 1, max = 65535) {
40
55
  if (!Number.isInteger(value) || value < min || value > max) {
@@ -120,21 +135,65 @@ function portSpec(value, field) {
120
135
  ...(optionalString(input.env, `${field}.env`) ? { env: optionalString(input.env, `${field}.env`) } : {}),
121
136
  };
122
137
  }
123
- function environmentFields(input, field) {
124
- const env = stringMap(input.env, `${field}.env`);
125
- const envAllowlist = stringArray(input.envAllowlist, `${field}.envAllowlist`);
126
- const secretEnv = stringArray(input.secretEnv, `${field}.secretEnv`);
138
+ function assertUniqueEnvironmentKeys(keys, field) {
139
+ const seen = new Set();
140
+ for (const key of keys ?? []) {
141
+ const folded = key.toUpperCase();
142
+ if (seen.has(folded))
143
+ fail(`${field} has colliding environment keys for ${key}.`, field);
144
+ seen.add(folded);
145
+ }
146
+ }
147
+ function assertSecretEnvironmentChannels(env, envAllowlist, secretEnv, field) {
127
148
  for (const key of Object.keys(env ?? {}))
128
- if (SENSITIVE_KEY.test(key))
149
+ if (isCredentialKey(key))
129
150
  fail(`${field}.env.${key} must not contain a literal secret; inherit it through envAllowlist and declare it in secretEnv.`, `${field}.env.${key}`);
130
151
  for (const key of secretEnv ?? [])
131
152
  if (!envAllowlist?.includes(key))
132
153
  fail(`${field}.secretEnv contains ${key}, which is not present in envAllowlist.`, `${field}.secretEnv`);
133
154
  for (const key of envAllowlist ?? [])
134
- if (SENSITIVE_KEY.test(key) && !secretEnv?.includes(key))
155
+ if (isCredentialKey(key) && !secretEnv?.includes(key))
135
156
  fail(`${field}.envAllowlist contains sensitive key ${key}; declare it in secretEnv for log redaction.`, `${field}.envAllowlist`);
157
+ }
158
+ function environmentFields(input, field, allowHost = false) {
159
+ const env = stringMap(input.env, `${field}.env`, allowHost);
160
+ const envAllowlist = stringArray(input.envAllowlist, `${field}.envAllowlist`);
161
+ const secretEnv = stringArray(input.secretEnv, `${field}.secretEnv`);
162
+ assertUniqueEnvironmentKeys(envAllowlist, `${field}.envAllowlist`);
163
+ assertUniqueEnvironmentKeys(secretEnv, `${field}.secretEnv`);
164
+ for (const key of envAllowlist ?? [])
165
+ environmentKey(key, `${field}.envAllowlist`, allowHost);
166
+ for (const key of secretEnv ?? [])
167
+ environmentKey(key, `${field}.secretEnv`, allowHost);
168
+ assertSecretEnvironmentChannels(env, envAllowlist, secretEnv, field);
136
169
  return { ...(env ? { env } : {}), ...(envAllowlist ? { envAllowlist } : {}), ...(secretEnv ? { secretEnv } : {}) };
137
170
  }
171
+ function taskSpec(value, field) {
172
+ if (value === undefined)
173
+ return undefined;
174
+ const input = record(value, field);
175
+ const rerun = input.rerun === undefined ? "on-change" : string(input.rerun, `${field}.rerun`);
176
+ if (rerun !== "on-change" && rerun !== "always" && rerun !== "once")
177
+ fail(`${field}.rerun must be on-change, always or once.`, `${field}.rerun`);
178
+ const idempotent = optionalBoolean(input.idempotent, `${field}.idempotent`) ?? false;
179
+ const retries = input.retries === undefined ? 0 : integer(input.retries, `${field}.retries`, 0, 5);
180
+ if (rerun === "always" && !idempotent)
181
+ fail(`${field}.rerun always requires idempotent: true.`, `${field}.rerun`);
182
+ if (retries > 0 && !idempotent)
183
+ fail(`${field}.retries requires idempotent: true; a non-idempotent task is never repeated automatically.`, `${field}.retries`);
184
+ const inputs = uniqueNames(input.inputs, `${field}.inputs`)?.map((item, index) => relativePath(item, `${field}.inputs[${index}]`));
185
+ return {
186
+ rerun, idempotent, retries,
187
+ timeoutMs: input.timeoutMs === undefined ? 600_000 : integer(input.timeoutMs, `${field}.timeoutMs`, 1, 86_400_000),
188
+ ...(inputs ? { inputs } : {}),
189
+ };
190
+ }
191
+ /** A task serves nothing: no ports, readiness probe or persistence, and never public. */
192
+ function assertFiniteTask(input, field, keys) {
193
+ for (const key of keys)
194
+ if (input[key] !== undefined)
195
+ fail(`${field}.${key} cannot be declared by a task; a task is ready when it exits successfully.`, `${field}.${key}`);
196
+ }
138
197
  function processSpec(value, field) {
139
198
  const input = record(value, field);
140
199
  const adapter = string(input.adapter, `${field}.adapter`);
@@ -152,18 +211,26 @@ function processSpec(value, field) {
152
211
  fail(`${field}.exposure must be local or public.`, `${field}.exposure`);
153
212
  const ports = stringArray(input.ports, `${field}.ports`);
154
213
  const dependsOn = stringArray(input.dependsOn, `${field}.dependsOn`);
214
+ const resources = uniqueNames(input.resources, `${field}.resources`);
155
215
  const parsedHealth = health(input.health, `${field}.health`);
216
+ const task = taskSpec(input.task, `${field}.task`);
217
+ if (task)
218
+ assertFiniteTask(input, field, ["ports", "health"]);
219
+ if (task && exposure === "public")
220
+ fail(`${field}.exposure cannot be public for a task; a task serves nothing.`, `${field}.exposure`);
156
221
  return {
157
222
  adapter,
158
223
  exposure,
159
224
  ...(command ? { command } : {}),
160
225
  ...(script ? { script } : {}),
161
226
  ...(input.cwd === undefined ? {} : { cwd: relativePath(input.cwd, `${field}.cwd`) }),
162
- ...environmentFields(input, field),
227
+ ...environmentFields(input, field, exposure === "public"),
163
228
  ...(ports ? { ports } : {}),
164
229
  ...(dependsOn ? { dependsOn } : {}),
230
+ ...(resources ? { resources } : {}),
165
231
  ...(parsedHealth ? { health: parsedHealth } : {}),
166
232
  ...(input.shutdownTimeoutMs === undefined ? {} : { shutdownTimeoutMs: integer(input.shutdownTimeoutMs, `${field}.shutdownTimeoutMs`, 1, 3_600_000) }),
233
+ ...(task ? { task } : {}),
167
234
  };
168
235
  }
169
236
  function serviceSpec(value, field) {
@@ -172,7 +239,11 @@ function serviceSpec(value, field) {
172
239
  fail(`${field}.adapter must be compose.`, `${field}.adapter`);
173
240
  const ports = input.ports === undefined ? undefined : Object.fromEntries(Object.entries(record(input.ports, `${field}.ports`)).map(([name, internal]) => [name, integer(internal, `${field}.ports.${name}`)]));
174
241
  const dependsOn = stringArray(input.dependsOn, `${field}.dependsOn`);
242
+ const resources = uniqueNames(input.resources, `${field}.resources`);
175
243
  const parsedHealth = health(input.health, `${field}.health`);
244
+ const task = taskSpec(input.task, `${field}.task`);
245
+ if (task)
246
+ assertFiniteTask(input, field, ["ports", "health", "persistent"]);
176
247
  return {
177
248
  adapter: "compose",
178
249
  service: string(input.service, `${field}.service`),
@@ -180,9 +251,11 @@ function serviceSpec(value, field) {
180
251
  ...(optionalString(input.projectName, `${field}.projectName`) ? { projectName: optionalString(input.projectName, `${field}.projectName`) } : {}),
181
252
  ...(ports ? { ports } : {}),
182
253
  ...(dependsOn ? { dependsOn } : {}),
254
+ ...(resources ? { resources } : {}),
183
255
  ...(parsedHealth ? { health: parsedHealth } : {}),
184
256
  ...(input.persistent === undefined ? {} : { persistent: input.persistent === true }),
185
- ...environmentFields(input, field),
257
+ ...environmentFields(input, field, true),
258
+ ...(task ? { task } : {}),
186
259
  };
187
260
  }
188
261
  function profileSpec(value, field) {
@@ -190,21 +263,63 @@ function profileSpec(value, field) {
190
263
  const environment = stringMap(input.environment, `${field}.environment`);
191
264
  const proxy = optionalBoolean(input.proxy, `${field}.proxy`);
192
265
  for (const key of Object.keys(environment ?? {}))
193
- if (SENSITIVE_KEY.test(key))
266
+ if (isCredentialKey(key))
194
267
  fail(`${field}.environment.${key} must not contain a secret. Use process envAllowlist and secretEnv.`, `${field}.environment.${key}`);
268
+ const tunnel = input.tunnel === undefined ? undefined : profileTunnelSpec(input.tunnel, `${field}.tunnel`);
269
+ const resources = input.resources === undefined ? undefined : Object.fromEntries(Object.entries(record(input.resources, `${field}.resources`))
270
+ .map(([name, target]) => [name, string(target, `${field}.resources.${name}`)]));
195
271
  return {
196
272
  ...(stringArray(input.processes, `${field}.processes`) ? { processes: stringArray(input.processes, `${field}.processes`) } : {}),
197
273
  ...(stringArray(input.services, `${field}.services`) ? { services: stringArray(input.services, `${field}.services`) } : {}),
198
274
  ...(environment ? { environment } : {}),
199
275
  ...(proxy === undefined ? {} : { proxy }),
276
+ ...(tunnel ? { tunnel } : {}),
277
+ ...(resources ? { resources } : {}),
200
278
  };
201
279
  }
280
+ function profileTunnelSpec(value, field) {
281
+ const input = record(value, field);
282
+ for (const key of Object.keys(input))
283
+ if (!["binding", "routes", "ephemeral"].includes(key))
284
+ fail(`${field}.${key} is not a tunnel field; credentials and provider accounts belong to the machine binding.`, `${field}.${key}`);
285
+ const binding = string(input.binding, `${field}.binding`);
286
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/.test(binding))
287
+ fail(`${field}.binding must be a registered tunnel binding name.`, `${field}.binding`);
288
+ const routes = stringArray(input.routes, `${field}.routes`) ?? [];
289
+ if (!routes.length || new Set(routes).size !== routes.length)
290
+ fail(`${field}.routes must name each public route once.`, `${field}.routes`);
291
+ const ephemeral = optionalBoolean(input.ephemeral, `${field}.ephemeral`);
292
+ return { binding, routes, ...(ephemeral ? { ephemeral } : {}) };
293
+ }
202
294
  function hostnameSpec(value, field) {
203
295
  const input = record(value, field);
204
296
  const tls = input.tls === undefined ? "off" : string(input.tls, `${field}.tls`);
205
297
  if (tls !== "off" && tls !== "internal")
206
298
  fail(`${field}.tls must be off or internal.`, `${field}.tls`);
207
299
  const hostname = optionalString(input.hostname, `${field}.hostname`);
300
+ const domain = optionalString(input.domain, `${field}.domain`);
301
+ const host = optionalString(input.host, `${field}.host`);
302
+ // The generated alias adds a separator and a 22-character minimum
303
+ // worktree suffix; a longer host cannot fit in one DNS label.
304
+ if (host && (!domain || host.length > 40 || !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(host)))
305
+ fail(`${field}.host requires a registered domain and one DNS label of at most 40 characters.`, `${field}.host`);
306
+ if (domain && hostname)
307
+ fail(`${field} cannot set both hostname and domain.`, field);
308
+ if (domain && (domain.length > 253 || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(domain) || domain.endsWith(".localhost"))) {
309
+ fail(`${field}.domain must be a concrete registered domain.`, `${field}.domain`);
310
+ }
311
+ if (domain && input.tls !== undefined)
312
+ fail(`${field}.tls is controlled by the machine domain registration; omit it from the manifest.`, `${field}.tls`);
313
+ const routePath = optionalString(input.path, `${field}.path`);
314
+ if (routePath && (!/^\/(?:[A-Za-z0-9._~!$&'()+,;=:@/-]*)$/.test(routePath) || routePath.includes("//") || routePath.split("/").some((part) => part === "." || part === ".."))) {
315
+ fail(`${field}.path must be an unambiguous absolute URL path.`, `${field}.path`);
316
+ }
317
+ const match = input.match === undefined ? undefined : string(input.match, `${field}.match`);
318
+ if (match && match !== "exact" && match !== "prefix")
319
+ fail(`${field}.match must be exact or prefix.`, `${field}.match`);
320
+ const stripPrefix = optionalBoolean(input.stripPrefix, `${field}.stripPrefix`);
321
+ if (stripPrefix && (match === "exact" || !routePath || routePath === "/"))
322
+ fail(`${field}.stripPrefix requires a non-root prefix path.`, `${field}.stripPrefix`);
208
323
  const expandedHostname = hostname?.replaceAll("{project}", "project").replaceAll("{instance}", "instance");
209
324
  if (hostname && (expandedHostname?.includes("{") || expandedHostname?.includes("}") || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+localhost$/i.test(expandedHostname))) {
210
325
  fail(`${field}.hostname must be a .localhost hostname using only {project} and {instance} placeholders.`, `${field}.hostname`);
@@ -212,10 +327,244 @@ function hostnameSpec(value, field) {
212
327
  return {
213
328
  target: string(input.target, `${field}.target`),
214
329
  ...(hostname ? { hostname } : {}),
215
- tls,
330
+ ...(domain ? { domain } : {}),
331
+ ...(host ? { host } : {}),
332
+ ...(routePath ? { path: routePath } : {}),
333
+ ...(match ? { match: match } : {}),
334
+ ...(stripPrefix === undefined ? {} : { stripPrefix }),
335
+ ...(domain ? {} : { tls }),
216
336
  ...(stringArray(input.profiles, `${field}.profiles`) ? { profiles: stringArray(input.profiles, `${field}.profiles`) } : {}),
217
337
  };
218
338
  }
339
+ function uniqueNames(value, field) {
340
+ const names = stringArray(value, field);
341
+ if (names && new Set(names).size !== names.length)
342
+ fail(`${field} must name each entry once.`, field);
343
+ return names;
344
+ }
345
+ const RESOURCE_KINDS = ["service", "database", "broker", "cache", "object-store", "browser"];
346
+ const RESOURCE_NAME = /^[a-z][a-z0-9_-]{0,39}$/;
347
+ const RESOURCE_MEMBER = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
348
+ const DATABASE_IDENTIFIER = /^[a-z][a-z0-9_]{0,62}$/;
349
+ // An isolated namespace appends a separator and a 13-character owner token.
350
+ const NAMESPACE_BASE = { database: /^[a-z][a-z0-9_]{0,48}$/, other: /^[a-z0-9][a-z0-9_-]{0,47}$/ };
351
+ const DEFAULT_PORTS = { "http:": "80", "https:": "443", "ws:": "80", "wss:": "443" };
352
+ /** Normalized environment token of a resource or member name. */
353
+ export function resourceToken(name) {
354
+ return name.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
355
+ }
356
+ /** Every generated key a resource can emit, whichever target a profile selects. */
357
+ export function resourceEnvironmentKeys(name, spec) {
358
+ const prefix = `DEVFN_RESOURCE_${resourceToken(name)}_`;
359
+ const suffixes = [];
360
+ if (spec.kind !== "browser")
361
+ suffixes.push("HOST", "PORT", "ENDPOINT", "URL", "HOST_ENDPOINT", "INTERNAL_ENDPOINT");
362
+ if (spec.kind !== "service")
363
+ suffixes.push("NAMESPACE");
364
+ if (spec.kind === "database")
365
+ suffixes.push("DATABASE", ...(spec.namespace?.level === "schema" ? ["SCHEMA"] : []));
366
+ if (spec.kind === "cache" || spec.kind === "object-store")
367
+ suffixes.push("PREFIX");
368
+ if (spec.kind === "broker")
369
+ suffixes.push(...(spec.topics ?? []).map((topic) => `TOPIC_${resourceToken(topic)}`), "CONSUMER_GROUP");
370
+ if (spec.kind === "browser") {
371
+ suffixes.push(...(spec.cookies ?? []).map((cookie) => `COOKIE_${resourceToken(cookie)}`));
372
+ suffixes.push(...(spec.storageKeys ?? []).map((key) => `STORAGE_${resourceToken(key)}`));
373
+ }
374
+ return suffixes.map((suffix) => `${prefix}${suffix}`);
375
+ }
376
+ function resourceMembers(value, field) {
377
+ const names = uniqueNames(value, field);
378
+ const tokens = new Set();
379
+ for (const name of names ?? []) {
380
+ if (!RESOURCE_MEMBER.test(name))
381
+ fail(`${field} entries must use letters, numbers, underscores, and hyphens.`, field);
382
+ if (tokens.has(resourceToken(name)))
383
+ fail(`${field} has ambiguous names that normalize to ${resourceToken(name)}.`, field);
384
+ tokens.add(resourceToken(name));
385
+ }
386
+ return names;
387
+ }
388
+ function secretReferences(value, field) {
389
+ if (value === undefined)
390
+ return undefined;
391
+ return Object.fromEntries(Object.entries(record(value, field)).map(([name, environment]) => {
392
+ if (!/^[a-z][a-z0-9_]{0,39}$/.test(name))
393
+ fail(`${field}.${name} must be a lowercase secret field name.`, `${field}.${name}`);
394
+ const key = string(environment, `${field}.${name}`);
395
+ environmentKey(key, `${field}.${name}`);
396
+ return [name, key];
397
+ }));
398
+ }
399
+ function remoteResourceUrl(value, kind, field) {
400
+ const raw = string(value, field);
401
+ let url;
402
+ try {
403
+ url = new URL(raw);
404
+ }
405
+ catch {
406
+ fail(`${field} must be an absolute URL.`, field);
407
+ }
408
+ if (url.username || url.password)
409
+ fail(`${field} must not embed credentials; name them in secrets.`, field);
410
+ // Clients decode percent escapes, so an encoded assignment, query or
411
+ // userinfo would reach them as a credential. A target URL is only a host,
412
+ // port and plain path.
413
+ if (raw.includes("%"))
414
+ fail(`${field} must not contain percent-encoded data; name credentials in secrets.`, field);
415
+ if (url.search || url.hash || raw.includes("?") || raw.includes("#"))
416
+ fail(`${field} must not contain a query or fragment; add options in a template.`, field);
417
+ if (!url.hostname)
418
+ fail(`${field} requires a host.`, field);
419
+ if (!url.port && !DEFAULT_PORTS[url.protocol])
420
+ fail(`${field} requires an explicit port for ${url.protocol} targets.`, field);
421
+ if (/[=;@]/.test(url.pathname))
422
+ fail(`${field} path must not carry assignments or credentials.`, field);
423
+ if (kind === "database" && url.pathname && url.pathname !== "/")
424
+ fail(`${field} cannot name a database; the database comes from the resource namespace.`, field);
425
+ return raw;
426
+ }
427
+ function resourceTarget(value, kind, field) {
428
+ const input = record(value, field);
429
+ const secrets = secretReferences(input.secrets, `${field}.secrets`);
430
+ if (input.url !== undefined) {
431
+ for (const key of Object.keys(input))
432
+ if (!["url", "secrets"].includes(key))
433
+ fail(`${field}.${key} is not a remote target field.`, `${field}.${key}`);
434
+ return { url: remoteResourceUrl(input.url, kind, `${field}.url`), ...(secrets ? { secrets } : {}) };
435
+ }
436
+ for (const key of Object.keys(input))
437
+ if (!["node", "port", "scheme", "path", "secrets"].includes(key))
438
+ fail(`${field}.${key} is not a local target field.`, `${field}.${key}`);
439
+ const scheme = optionalString(input.scheme, `${field}.scheme`);
440
+ if (scheme && !/^[a-z][a-z0-9+.-]{0,31}$/.test(scheme))
441
+ fail(`${field}.scheme must be a URL scheme without "://".`, `${field}.scheme`);
442
+ const targetPath = optionalString(input.path, `${field}.path`);
443
+ if (targetPath && (!/^\/[A-Za-z0-9._~!$&'()+,:/-]*$/.test(targetPath) || targetPath.split("/").some((part) => part === "." || part === ".."))) {
444
+ fail(`${field}.path must be an absolute URL path without queries, assignments, or dot segments.`, `${field}.path`);
445
+ }
446
+ if (targetPath && kind === "database")
447
+ fail(`${field}.path cannot name a database; the database comes from the resource namespace.`, `${field}.path`);
448
+ return { node: string(input.node, `${field}.node`), port: string(input.port, `${field}.port`),
449
+ ...(scheme ? { scheme } : {}), ...(targetPath ? { path: targetPath } : {}), ...(secrets ? { secrets } : {}) };
450
+ }
451
+ function resourceNamespace(value, kind, field) {
452
+ if (value === undefined)
453
+ return undefined;
454
+ if (kind === "service")
455
+ fail(`${field} is unavailable for service resources.`, field);
456
+ const input = record(value, field);
457
+ for (const key of Object.keys(input))
458
+ if (!["base", "sharing", "level", "database"].includes(key))
459
+ fail(`${field}.${key} is not a namespace field.`, `${field}.${key}`);
460
+ const base = optionalString(input.base, `${field}.base`);
461
+ if (base && !NAMESPACE_BASE[kind === "database" ? "database" : "other"].test(base))
462
+ fail(`${field}.base is not a valid ${kind} namespace base.`, `${field}.base`);
463
+ const sharing = input.sharing === undefined ? undefined : string(input.sharing, `${field}.sharing`);
464
+ if (sharing !== undefined && sharing !== "isolated" && sharing !== "shared")
465
+ fail(`${field}.sharing must be isolated or shared.`, `${field}.sharing`);
466
+ const level = input.level === undefined ? undefined : string(input.level, `${field}.level`);
467
+ if (level !== undefined && (kind !== "database" || (level !== "database" && level !== "schema")))
468
+ fail(`${field}.level must be database or schema on a database resource.`, `${field}.level`);
469
+ const database = optionalString(input.database, `${field}.database`);
470
+ if ((level === "schema") !== (database !== undefined))
471
+ fail(`${field}.database is required exactly when level is schema.`, `${field}.database`);
472
+ if (database && !DATABASE_IDENTIFIER.test(database))
473
+ fail(`${field}.database must be a lowercase database identifier.`, `${field}.database`);
474
+ return { ...(base ? { base } : {}), ...(sharing ? { sharing: sharing } : {}),
475
+ ...(level ? { level: level } : {}), ...(database ? { database } : {}) };
476
+ }
477
+ function resourceSpec(value, field) {
478
+ const input = record(value, field);
479
+ const kind = string(input.kind, `${field}.kind`);
480
+ if (!RESOURCE_KINDS.includes(kind))
481
+ fail(`${field}.kind must be one of ${RESOURCE_KINDS.join(", ")}.`, `${field}.kind`);
482
+ const allowed = new Set(["kind", "targets", "defaultTarget", "namespace",
483
+ ...(kind === "broker" ? ["topics", "consumerGroup"] : []), ...(kind === "browser" ? ["cookies", "storageKeys"] : [])]);
484
+ for (const key of Object.keys(input))
485
+ if (!allowed.has(key))
486
+ fail(`${field}.${key} is not a field of ${kind} resources.`, `${field}.${key}`);
487
+ if (kind === "browser" && (input.targets !== undefined || input.defaultTarget !== undefined))
488
+ fail(`${field} browser metadata has no targets.`, field);
489
+ const targets = input.targets === undefined ? undefined : named(input.targets, `${field}.targets`, (item, itemField) => resourceTarget(item, kind, itemField));
490
+ if (kind !== "browser" && (!targets || !Object.keys(targets).length))
491
+ fail(`${field}.targets must declare at least one target.`, `${field}.targets`);
492
+ for (const name of Object.keys(targets ?? {}))
493
+ if (!RESOURCE_NAME.test(name))
494
+ fail(`${field}.targets.${name} must be a lowercase target name.`, `${field}.targets.${name}`);
495
+ const defaultTarget = optionalString(input.defaultTarget, `${field}.defaultTarget`);
496
+ if (defaultTarget && !Object.hasOwn(targets ?? {}, defaultTarget))
497
+ fail(`${field}.defaultTarget references unknown target ${defaultTarget}.`, `${field}.defaultTarget`);
498
+ const topics = resourceMembers(input.topics, `${field}.topics`);
499
+ if (kind === "broker" && !topics?.length)
500
+ fail(`${field}.topics must declare at least one topic.`, `${field}.topics`);
501
+ const consumerGroup = optionalString(input.consumerGroup, `${field}.consumerGroup`);
502
+ if (consumerGroup && !RESOURCE_MEMBER.test(consumerGroup))
503
+ fail(`${field}.consumerGroup must use letters, numbers, underscores, and hyphens.`, `${field}.consumerGroup`);
504
+ const cookies = resourceMembers(input.cookies, `${field}.cookies`);
505
+ const storageKeys = resourceMembers(input.storageKeys, `${field}.storageKeys`);
506
+ const namespace = resourceNamespace(input.namespace, kind, `${field}.namespace`);
507
+ return {
508
+ kind,
509
+ ...(targets ? { targets } : {}),
510
+ ...(defaultTarget ? { defaultTarget } : {}),
511
+ ...(namespace ? { namespace } : {}),
512
+ ...(topics ? { topics } : {}),
513
+ ...(consumerGroup ? { consumerGroup } : {}),
514
+ ...(cookies ? { cookies } : {}),
515
+ ...(storageKeys ? { storageKeys } : {}),
516
+ };
517
+ }
518
+ function validateLocalResourceTarget(config, target, field) {
519
+ const process = config.processes && Object.hasOwn(config.processes, target.node) ? config.processes[target.node] : undefined;
520
+ const service = config.services && Object.hasOwn(config.services, target.node) ? config.services[target.node] : undefined;
521
+ if (!process && !service)
522
+ fail(`${field}.node references unknown lifecycle node ${target.node}.`, `${field}.node`);
523
+ if (!config.ports || !Object.hasOwn(config.ports, target.port))
524
+ fail(`${field}.port references unknown port ${target.port}.`, `${field}.port`);
525
+ const declared = process ? process.ports?.includes(target.port) : service.ports?.[target.port] !== undefined;
526
+ if (!declared)
527
+ fail(`${field}.port ${target.port} is not an exposed port declared by ${target.node}.`, `${field}.port`);
528
+ }
529
+ function validateResourceReferences(config) {
530
+ const resources = config.resources ?? {};
531
+ const tokens = new Map();
532
+ const keys = new Map();
533
+ for (const [name, spec] of Object.entries(resources)) {
534
+ if (!RESOURCE_NAME.test(name))
535
+ fail(`resources.${name} must be a lowercase resource name.`, `resources.${name}`);
536
+ const prior = tokens.get(resourceToken(name));
537
+ if (prior)
538
+ fail(`resources.${prior} and resources.${name} are ambiguous: both normalize to ${resourceToken(name)}.`, `resources.${name}`);
539
+ tokens.set(resourceToken(name), name);
540
+ for (const key of resourceEnvironmentKeys(name, spec)) {
541
+ const owner = keys.get(key);
542
+ if (owner && owner !== name)
543
+ fail(`resources.${owner} and resources.${name} both emit ${key}.`, `resources.${name}`);
544
+ keys.set(key, name);
545
+ }
546
+ for (const [targetName, target] of Object.entries(spec.targets ?? {})) {
547
+ if ("node" in target)
548
+ validateLocalResourceTarget(config, target, `resources.${name}.targets.${targetName}`);
549
+ }
550
+ }
551
+ const consumers = [...Object.entries(config.processes ?? {}).map(([name, spec]) => [`processes.${name}`, spec.resources]),
552
+ ...Object.entries(config.services ?? {}).map(([name, spec]) => [`services.${name}`, spec.resources])];
553
+ for (const [field, consumed] of consumers) {
554
+ for (const resource of consumed ?? [])
555
+ if (!Object.hasOwn(resources, resource))
556
+ fail(`${field}.resources references unknown resource ${resource}.`, `${field}.resources`);
557
+ }
558
+ for (const [profileName, profile] of Object.entries(config.profiles)) {
559
+ for (const [resource, target] of Object.entries(profile.resources ?? {})) {
560
+ const field = `profiles.${profileName}.resources.${resource}`;
561
+ if (!Object.hasOwn(resources, resource))
562
+ fail(`${field} references unknown resource ${resource}.`, field);
563
+ if (!Object.hasOwn(resources[resource].targets ?? {}, target))
564
+ fail(`${field} references unknown target ${target}.`, field);
565
+ }
566
+ }
567
+ }
219
568
  function prerequisites(value) {
220
569
  if (value === undefined)
221
570
  return undefined;
@@ -295,6 +644,21 @@ function validateSelectionReferences(config, ports, processes, services) {
295
644
  for (const service of profile.services ?? [])
296
645
  if (!services.has(service))
297
646
  fail(`profiles.${name} references unknown service ${service}.`);
647
+ if (!profile.tunnel)
648
+ continue;
649
+ if (!profile.proxy)
650
+ fail(`profiles.${name}.tunnel requires proxy: true; public routes are served through the DevFn proxy.`, `profiles.${name}.tunnel`);
651
+ for (const route of profile.tunnel.routes) {
652
+ const hostname = config.hostnames?.[route];
653
+ if (!hostname || (hostname.profiles && !hostname.profiles.includes(name)))
654
+ fail(`profiles.${name}.tunnel.routes references ${route}, which is not a hostname route of this profile.`, `profiles.${name}.tunnel.routes`);
655
+ if (config.ports?.[hostname.target]?.protocol === "udp")
656
+ fail(`profiles.${name}.tunnel.routes cannot publish UDP port ${hostname.target}.`, `profiles.${name}.tunnel.routes`);
657
+ // The public hostname is <label>-<worktree>-<suffix>.<binding domain>, one DNS label under it.
658
+ const label = hostname.host ?? route;
659
+ if (label.length > 40 || !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
660
+ fail(`profiles.${name}.tunnel.routes: ${route} needs a lowercase DNS host label of at most 40 characters.`, `profiles.${name}.tunnel.routes`);
661
+ }
298
662
  }
299
663
  for (const [name, hostname] of Object.entries(config.hostnames ?? {})) {
300
664
  if (!ports.has(hostname.target))
@@ -327,19 +691,30 @@ function validateReferences(config) {
327
691
  validateProcessReferences(config, ports, nodes);
328
692
  validateServiceReferences(config, ports, nodes);
329
693
  validateSelectionReferences(config, ports, processes, services);
694
+ validateResourceReferences(config);
330
695
  const environmentOwners = new Map();
331
696
  for (const [name, spec] of Object.entries(config.ports ?? {})) {
332
- if (spec.env?.startsWith("DEVFN_"))
697
+ if (spec.env?.toUpperCase().startsWith("DEVFN_"))
333
698
  fail(`ports.${name}.env cannot use reserved DEVFN_ runtime variables.`, `ports.${name}.env`);
699
+ if (spec.env)
700
+ environmentKey(spec.env, `ports.${name}.env`);
334
701
  const generated = `DEVFN_PORT_${name.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
335
- for (const environmentName of new Set([generated, ...(spec.env ? [spec.env] : [])])) {
336
- const owner = environmentOwners.get(environmentName);
702
+ const generatedUrl = `DEVFN_URL_${name.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
703
+ for (const environmentName of new Set([generated, generatedUrl, ...(spec.env ? [spec.env] : [])])) {
704
+ const owner = environmentOwners.get(environmentName.toUpperCase());
337
705
  if (owner !== undefined && owner !== name)
338
706
  fail(`Ports ${owner} and ${name} both emit environment variable ${environmentName}.`, `ports.${name}`);
339
- environmentOwners.set(environmentName, name);
707
+ environmentOwners.set(environmentName.toUpperCase(), name);
340
708
  }
341
709
  }
342
710
  for (const [name, spec] of Object.entries(config.hostnames ?? {})) {
711
+ if (spec.domain) {
712
+ const label = spec.host ?? name;
713
+ if (label.length > 40 || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)) {
714
+ fail(`hostnames.${name} requires a registered-domain host label of at most 40 DNS-safe characters.`, `hostnames.${name}${spec.host ? ".host" : ""}`);
715
+ }
716
+ continue;
717
+ }
343
718
  const expanded = (spec.hostname ?? `${name}-{instance}.localhost`).replaceAll("{instance}", "instance").replaceAll("{project}", config.project.id);
344
719
  if (!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+localhost$/i.test(expanded))
345
720
  fail(`hostnames.${name}.hostname resolves to an invalid .localhost hostname for project ${config.project.id}.`, `hostnames.${name}.hostname`);
@@ -363,6 +738,7 @@ export function validateDevFnConfig(value) {
363
738
  ...(input.processes === undefined ? {} : { processes: named(input.processes, "processes", processSpec) }),
364
739
  ...(input.services === undefined ? {} : { services: named(input.services, "services", serviceSpec) }),
365
740
  ...(input.hostnames === undefined ? {} : { hostnames: named(input.hostnames, "hostnames", hostnameSpec) }),
741
+ ...(input.resources === undefined ? {} : { resources: named(input.resources, "resources", resourceSpec) }),
366
742
  ...(prerequisites(input.prerequisites) ? { prerequisites: prerequisites(input.prerequisites) } : {}),
367
743
  ...(environmentOutputs(input.environmentOutputs) ? { environmentOutputs: environmentOutputs(input.environmentOutputs) } : {}),
368
744
  ...(input.policy === undefined ? {} : { policy: relativePath(input.policy, "policy") }),
@@ -401,8 +777,10 @@ export function validateDevFnPolicy(value) {
401
777
  return input.ports.map((item, index) => policyEntry(item, `ports[${index}]`));
402
778
  })();
403
779
  const hostnameSuffix = optionalString(input.hostnameSuffix, "hostnameSuffix");
404
- if (hostnameSuffix && (!hostnameSuffix.startsWith(".") || !hostnameSuffix.endsWith(".localhost")))
405
- fail("hostnameSuffix must start with a dot and end in .localhost.", "hostnameSuffix");
780
+ // The shortest generated hostname adds a one-character route key and a
781
+ // 23-character "-o-<20 hex>" owner component, within 253 characters.
782
+ if (hostnameSuffix && (!/^\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*localhost$/i.test(hostnameSuffix) || hostnameSuffix.length > 229))
783
+ fail("hostnameSuffix must be a valid dot-prefixed .localhost suffix.", "hostnameSuffix");
406
784
  return {
407
785
  version: 1,
408
786
  ...(range(input.fallbackRange, "fallbackRange") ? { fallbackRange: range(input.fallbackRange, "fallbackRange") } : {}),
package/dist/trust.js CHANGED
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
- import { processBirthSignature, processExists } from "./process-identity.js";
5
+ import { processBirthSignature, processIdentityStatus } from "./process-identity.js";
6
6
  export function defaultStateDir(env = process.env) {
7
7
  return env.XDG_STATE_HOME ? path.join(env.XDG_STATE_HOME, "devfn") : path.join(os.homedir(), ".local", "state", "devfn");
8
8
  }
@@ -35,12 +35,8 @@ async function staleTicket(filePath, ticket, staleMs) {
35
35
  return false;
36
36
  if (!ticket?.pid)
37
37
  return true;
38
- if (!processExists(ticket.pid))
39
- return true;
40
- if (!ticket.birthSignature)
41
- return false;
42
- const currentSignature = await processBirthSignature(ticket.pid);
43
- return currentSignature !== undefined && currentSignature !== ticket.birthSignature;
38
+ const owner = await processIdentityStatus(ticket.pid, ticket.birthSignature, ticket.createdAt);
39
+ return owner === "exited" || owner === "identity-mismatch";
44
40
  }
45
41
  async function ticketPaths(lockPath) {
46
42
  return (await readdir(lockPath)).filter((name) => name.endsWith(".choosing") || name.endsWith(".ticket")).map((name) => path.join(lockPath, name));
package/dist/types.d.ts CHANGED
@@ -30,6 +30,32 @@ export interface PortSpec {
30
30
  block?: string;
31
31
  env?: string;
32
32
  }
33
+ /**
34
+ * A finite lifecycle node: it runs to completion instead of serving, and
35
+ * only its successful exit (code 0) lets dependents start. A task declares
36
+ * no ports or health check and is never a resource target provider.
37
+ */
38
+ export interface TaskSpec {
39
+ /**
40
+ * When a later start runs it again: "on-change" (default) when no success
41
+ * is recorded for its current input revision, "always" on every start
42
+ * (requires idempotent), "once" never after a success, failing before any
43
+ * mutation when its input revision changed.
44
+ */
45
+ rerun?: "on-change" | "always" | "once";
46
+ /**
47
+ * Repeating it, also after an attempt that may have partly applied, is
48
+ * safe. Without it a failed, timed-out, cancelled or interrupted attempt
49
+ * blocks later starts until the task is explicitly rerun. Defaults to false.
50
+ */
51
+ idempotent?: boolean;
52
+ /** Further attempts within one start after a failed or timed-out one; requires idempotent. 0-5, defaults to 0. */
53
+ retries?: number;
54
+ /** Per-attempt limit after which the task is stopped. Defaults to 600000. */
55
+ timeoutMs?: number;
56
+ /** Repository-relative files and directories whose content is part of the input revision. */
57
+ inputs?: string[];
58
+ }
33
59
  export interface ProcessSpec {
34
60
  adapter: RuntimeAdapter;
35
61
  exposure?: "local" | "public";
@@ -41,8 +67,12 @@ export interface ProcessSpec {
41
67
  secretEnv?: string[];
42
68
  ports?: string[];
43
69
  dependsOn?: string[];
70
+ /** Resources this node consumes; a selected local target starts before it. */
71
+ resources?: string[];
44
72
  health?: HealthCheck;
45
73
  shutdownTimeoutMs?: number;
74
+ /** Run as a finite task instead of a daemon. */
75
+ task?: TaskSpec;
46
76
  }
47
77
  export interface ComposeServiceSpec {
48
78
  adapter: "compose";
@@ -51,21 +81,63 @@ export interface ComposeServiceSpec {
51
81
  projectName?: string;
52
82
  ports?: Record<string, number>;
53
83
  dependsOn?: string[];
84
+ /** Resources this node consumes; a selected local target starts before it. */
85
+ resources?: string[];
54
86
  health?: HealthCheck;
55
87
  persistent?: boolean;
56
88
  env?: Record<string, string>;
57
89
  envAllowlist?: string[];
58
90
  secretEnv?: string[];
91
+ /** Run the service's command once in a one-off container (docker compose run) as a finite task. */
92
+ task?: TaskSpec;
93
+ }
94
+ /**
95
+ * Routes a profile publishes through a machine-registered public tunnel
96
+ * binding. Selecting it never authorizes exposure: every start still needs
97
+ * --allow-public.
98
+ */
99
+ export interface ProfileTunnelSpec {
100
+ /** Name of a machine tunnel binding registered to this repository and project. */
101
+ binding: string;
102
+ /** Keys of `hostnames` entries selected by this profile; only these are public. */
103
+ routes: string[];
104
+ /**
105
+ * Explicitly select a binding whose provider assigns public URLs only after
106
+ * its connector starts (ngrok ephemeral endpoints). Those URLs reach the
107
+ * receipt and tunnel status, never a pre-start template reference.
108
+ */
109
+ ephemeral?: boolean;
59
110
  }
60
111
  export interface ProfileSpec {
61
112
  processes?: string[];
62
113
  services?: string[];
63
114
  environment?: Record<string, string>;
64
115
  proxy?: boolean;
116
+ tunnel?: ProfileTunnelSpec;
117
+ /** Selected target name by resource; overrides the resource's defaultTarget. */
118
+ resources?: Record<string, string>;
65
119
  }
120
+ /**
121
+ * A proxy route. Validation accepts two variants: a .localhost route
122
+ * (optional `hostname` template and `tls`) or a registered-domain route
123
+ * (`domain`, optional `host`, TLS from the machine registration). `domain`
124
+ * excludes `hostname` and `tls`; `host` requires `domain`.
125
+ */
66
126
  export interface HostnameSpec {
67
127
  target: string;
128
+ /** .localhost hostname template using {project} and {instance}; excludes domain. */
68
129
  hostname?: string;
130
+ /** Machine-registered domain; a manifest cannot create the registration. */
131
+ domain?: string;
132
+ /** Registered-domain host label; defaults to the route key. Requires domain. */
133
+ host?: string;
134
+ /** Absolute URL path; defaults to "/". */
135
+ path?: string;
136
+ /** Defaults to "prefix". */
137
+ match?: "exact" | "prefix";
138
+ /** Strip the path prefix upstream; requires a non-root prefix path. Defaults to false. */
139
+ stripPrefix?: boolean;
140
+ /** .localhost routes only; defaults to "off". */
69
141
  tls?: "off" | "internal";
70
142
  profiles?: string[];
71
143
  }
@@ -80,6 +152,51 @@ export interface EnvironmentOutput {
80
152
  format?: "dotenv" | "json";
81
153
  mode?: number;
82
154
  }
155
+ export type ResourceKind = "service" | "database" | "broker" | "cache" | "object-store" | "browser";
156
+ /**
157
+ * A resource target. A local target names the lifecycle node and declared
158
+ * port that provide it; a remote target names a credential-free URL. Secret
159
+ * fields name host environment variables, never values.
160
+ */
161
+ export type ResourceTargetSpec = {
162
+ node: string;
163
+ port: string;
164
+ scheme?: string;
165
+ path?: string;
166
+ secrets?: Record<string, string>;
167
+ } | {
168
+ url: string;
169
+ secrets?: Record<string, string>;
170
+ };
171
+ /**
172
+ * Isolated namespaces append an opaque runtime-owner token; shared namespaces
173
+ * use `base` unchanged across runtimes. Sharing is never implied.
174
+ */
175
+ export interface ResourceNamespaceSpec {
176
+ /** Defaults to the resource name. */
177
+ base?: string;
178
+ /** Defaults to "isolated". */
179
+ sharing?: "isolated" | "shared";
180
+ /** Database resources: namespace the database (default) or a schema in `database`. */
181
+ level?: "database" | "schema";
182
+ /** Fixed database containing namespaced schemas; requires level "schema". */
183
+ database?: string;
184
+ }
185
+ export interface ResourceSpec {
186
+ kind: ResourceKind;
187
+ /** Required for every kind except browser. */
188
+ targets?: Record<string, ResourceTargetSpec>;
189
+ defaultTarget?: string;
190
+ namespace?: ResourceNamespaceSpec;
191
+ /** Broker topics; each receives a namespaced name. */
192
+ topics?: string[];
193
+ /** Broker consumer group suffix; the group shares the topics' namespace. */
194
+ consumerGroup?: string;
195
+ /** Browser cookie names that receive namespaced names. */
196
+ cookies?: string[];
197
+ /** Browser storage keys that receive namespaced names. */
198
+ storageKeys?: string[];
199
+ }
83
200
  export interface DevFnConfig {
84
201
  version: 1;
85
202
  project: {
@@ -93,6 +210,7 @@ export interface DevFnConfig {
93
210
  services?: Record<string, ComposeServiceSpec>;
94
211
  profiles: Record<string, ProfileSpec>;
95
212
  hostnames?: Record<string, HostnameSpec>;
213
+ resources?: Record<string, ResourceSpec>;
96
214
  prerequisites?: RuntimePrerequisite[];
97
215
  environmentOutputs?: EnvironmentOutput[];
98
216
  policy?: string;
package/package.json CHANGED
@@ -1,12 +1,25 @@
1
1
  {
2
2
  "name": "@devfn/config",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Validated project manifests, organization policies, discovery, and trust contracts for DevFn",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/21nCo/superfunctions.git",
8
+ "directory": "devfn/config"
9
+ },
5
10
  "type": "module",
6
11
  "main": "./dist/index.js",
7
12
  "types": "./dist/index.d.ts",
8
- "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
9
- "files": ["dist", "README.md"],
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md"
22
+ ],
10
23
  "scripts": {
11
24
  "build": "tsc -p tsconfig.json",
12
25
  "typecheck": "tsc --noEmit -p tsconfig.json",
@@ -14,8 +27,17 @@
14
27
  "lint": "echo 'lint not configured'",
15
28
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
16
29
  },
17
- "dependencies": { "@clifn/core": "0.1.0", "@types/node": "^22.0.0", "typescript": "^5.6.0" },
18
- "devDependencies": { "vitest": "^3.2.4" },
19
- "engines": { "node": ">=22" },
20
- "license": "MIT"
30
+ "dependencies": {
31
+ "@clifn/core": "0.1.1",
32
+ "@types/node": "^22.0.0",
33
+ "typescript": "^5.6.0"
34
+ },
35
+ "devDependencies": {
36
+ "vitest": "^3.2.4"
37
+ },
38
+ "engines": {
39
+ "node": ">=22"
40
+ },
41
+ "license": "MIT",
42
+ "gitHead": "699d9d09932199d660333c57fe7bc646492ea3bf"
21
43
  }