@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.
- package/dist/credentials.d.ts +5 -0
- package/dist/credentials.js +71 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/process-identity.d.ts +46 -1
- package/dist/process-identity.js +221 -12
- package/dist/schema.d.ts +5 -1
- package/dist/schema.js +397 -19
- package/dist/trust.js +3 -7
- package/dist/types.d.ts +118 -0
- package/package.json +29 -7
|
@@ -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
|
|
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>;
|
package/dist/process-identity.js
CHANGED
|
@@ -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
|
|
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
|
|
14
|
-
return `darwin:${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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
|
-
|
|
405
|
-
|
|
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,
|
|
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
|
-
|
|
39
|
-
|
|
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.
|
|
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": {
|
|
9
|
-
|
|
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": {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
}
|