@homeflare/site 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -0
  3. package/dist/checkout.d.ts +26 -0
  4. package/dist/checkout.d.ts.map +1 -0
  5. package/dist/decode.d.ts +26 -0
  6. package/dist/decode.d.ts.map +1 -0
  7. package/dist/derive.d.ts +66 -0
  8. package/dist/derive.d.ts.map +1 -0
  9. package/dist/errors.d.ts +45 -0
  10. package/dist/errors.d.ts.map +1 -0
  11. package/dist/guards.d.ts +19 -0
  12. package/dist/guards.d.ts.map +1 -0
  13. package/dist/identity.d.ts +33 -0
  14. package/dist/identity.d.ts.map +1 -0
  15. package/dist/index-dvzn0279.js +288 -0
  16. package/dist/index-dvzn0279.js.map +17 -0
  17. package/dist/index.d.ts +25 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +238 -0
  20. package/dist/index.js.map +14 -0
  21. package/dist/inventory.d.ts +12 -0
  22. package/dist/inventory.d.ts.map +1 -0
  23. package/dist/load.d.ts +34 -0
  24. package/dist/load.d.ts.map +1 -0
  25. package/dist/load.js +190 -0
  26. package/dist/load.js.map +12 -0
  27. package/dist/net.d.ts +5 -0
  28. package/dist/net.d.ts.map +1 -0
  29. package/dist/overrides.d.ts +45 -0
  30. package/dist/overrides.d.ts.map +1 -0
  31. package/dist/pins.d.ts +15 -0
  32. package/dist/pins.d.ts.map +1 -0
  33. package/dist/primitives.d.ts +57 -0
  34. package/dist/primitives.d.ts.map +1 -0
  35. package/dist/references.d.ts +6 -0
  36. package/dist/references.d.ts.map +1 -0
  37. package/dist/schema.d.ts +100 -0
  38. package/dist/schema.d.ts.map +1 -0
  39. package/dist/tokens.d.ts +24 -0
  40. package/dist/tokens.d.ts.map +1 -0
  41. package/dist/version.d.ts +2 -0
  42. package/dist/version.d.ts.map +1 -0
  43. package/package.json +41 -0
  44. package/site.example.json +81 -0
  45. package/src/checkout.ts +109 -0
  46. package/src/decode.ts +99 -0
  47. package/src/derive.ts +188 -0
  48. package/src/errors.ts +60 -0
  49. package/src/guards.ts +42 -0
  50. package/src/identity.ts +93 -0
  51. package/src/index.ts +43 -0
  52. package/src/inventory.ts +55 -0
  53. package/src/load.ts +186 -0
  54. package/src/net.ts +29 -0
  55. package/src/overrides.ts +79 -0
  56. package/src/pins.ts +40 -0
  57. package/src/primitives.ts +139 -0
  58. package/src/references.ts +82 -0
  59. package/src/schema.ts +144 -0
  60. package/src/tokens.ts +60 -0
  61. package/src/version.ts +2 -0
package/src/errors.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The one error this package throws.
3
+ *
4
+ * ★ ONE CLASS, A `code` TO SWITCH ON. Callers mostly print the message and stop; the few
5
+ * that branch (a scaffold harness skipping `derive-version` in a dry run, say) branch on
6
+ * `code`, which is stable, rather than on message text, which is not.
7
+ *
8
+ * ⛔ Messages name paths, keys and the fix — never a value from a live file beyond the
9
+ * one being complained about. A site file is private, and an error string ends up in CI
10
+ * logs.
11
+ */
12
+
13
+ export type SiteErrorCode =
14
+ /** The file does not match the schema. `issues` lists `path: problem`. */
15
+ | 'decode'
16
+ /** A value points at something the file does not declare (a zone, host, network). */
17
+ | 'reference'
18
+ /** A consumer asked for a product, service, zone, host or pin nobody declared. */
19
+ | 'unknown-key'
20
+ /** The file was reviewed against a different `@homeflare/site` version. */
21
+ | 'derive-version'
22
+ /** A non-live site was asked to plan stage `live`. */
23
+ | 'stage'
24
+ /** The observed vault or account is not the one the site describes. */
25
+ | 'identity'
26
+ /** The file could not be located, read or parsed. */
27
+ | 'load'
28
+ /** The file is not committed on `main` and `siteDev` was not set. */
29
+ | 'checkout'
30
+ /** An environment override was refused. */
31
+ | 'override';
32
+
33
+ export class SiteError extends Error {
34
+ override readonly name = 'SiteError';
35
+ readonly code: SiteErrorCode;
36
+ /** One line per problem, each naming its path. Empty when the message says it all. */
37
+ readonly issues: readonly string[];
38
+
39
+ constructor(code: SiteErrorCode, message: string, issues: readonly string[] = []) {
40
+ super(issues.length === 0 ? message : `${message}\n ${issues.join('\n ')}`);
41
+ this.code = code;
42
+ this.issues = issues;
43
+ }
44
+ }
45
+
46
+ /** Throw `unknown-key`, listing what IS declared so the fix is one glance away. */
47
+ export function unknownKey(kind: string, key: string, declared: Iterable<string>): never {
48
+ const known = [...declared].sort();
49
+ const list = known.length === 0 ? 'none are declared' : `declared: ${known.join(', ')}`;
50
+ throw new SiteError('unknown-key', `unknown ${kind} "${key}" (${list})`);
51
+ }
52
+
53
+ /**
54
+ * Look a key up in a record, or throw `unknown-key`.
55
+ * ⛔ No fallback, ever. A plausible default host is how a typo becomes a live DNS record.
56
+ */
57
+ export function lookup<V>(kind: string, record: Readonly<Record<string, V>>, key: string): V {
58
+ if (!Object.hasOwn(record, key)) unknownKey(kind, key, Object.keys(record));
59
+ return record[key] as V;
60
+ }
package/src/guards.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Guards that refuse a plan before it can touch the wrong thing.
3
+ *
4
+ * ⛔ EACH ONE FAILS CLOSED. A guard that warns is a guard nobody reads in a CI log.
5
+ */
6
+ import { SiteError } from './errors.ts';
7
+ import type { Site } from './schema.ts';
8
+
9
+ /**
10
+ * Refuse a site reviewed against a different `@homeflare/site` than the one installed.
11
+ *
12
+ * ★ WHY EXACT EQUALITY. Derive rules are this package's behaviour. If a new version builds
13
+ * one hostname differently, every repo on it would plan a rename — a REPLACE — without
14
+ * anyone having looked. Holding `deriveVersion` equal to the installed version makes that
15
+ * a deliberate, reviewed one-line bump in the site file instead.
16
+ */
17
+ export function checkDeriveVersion(site: Site, installed: string): void {
18
+ if (site.deriveVersion === installed) return;
19
+ throw new SiteError(
20
+ 'derive-version',
21
+ `the site file was reviewed against @homeflare/site ${site.deriveVersion}, ` +
22
+ `but ${installed} is installed. Read the CHANGELOG between them for derive changes, ` +
23
+ `then set "deriveVersion": "${installed}" — or install ${site.deriveVersion}.`,
24
+ );
25
+ }
26
+
27
+ /** Stages as a deploy tool names them. Only `live` is special here. */
28
+ export type Stage = string;
29
+
30
+ /**
31
+ * ⛔ STAGE `live` ONLY WITH A LIVE SITE. An example or testing site can never plan
32
+ * against live state — its account ids and cluster name are placeholders, and a plan
33
+ * against real state would read every live object as "not declared: delete".
34
+ */
35
+ export function assertStage(site: Site, stage: Stage): void {
36
+ if (stage !== 'live' || site.kind === 'live') return;
37
+ throw new SiteError(
38
+ 'stage',
39
+ `stage "live" needs a live site; this one is kind "${site.kind}". ` +
40
+ `Point HF_SITE_FILE at the live site file, or plan a non-live stage.`,
41
+ );
42
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Compare the identity a site EXPECTS with the identity a live system REPORTS.
3
+ *
4
+ * ⛔ WHY EVERY LIVE PLAN CALLS THIS. An exported `BAO_ADDR` from another shell points a
5
+ * stack at a different vault with nothing else looking wrong: the token works, the
6
+ * mounts exist, and the plan reads that vault's objects as drift to "fix". Only the
7
+ * vault's own `cluster_name` (from `sys/health`) and the Cloudflare account id say which
8
+ * system is on the other end.
9
+ *
10
+ * ★ PURE. This module fetches nothing. The caller reads `sys/health` and the account with
11
+ * its own client (the kit's OpenBao and Cloudflare resources already have one) and hands
12
+ * the observed values in, so this runs anywhere and is trivial to test.
13
+ */
14
+ import { SiteError, lookup } from './errors.ts';
15
+ import type { Site } from './schema.ts';
16
+
17
+ export interface Identity {
18
+ /** `cluster_name` from the vault's `sys/health`. */
19
+ readonly vaultClusterName?: string;
20
+ /** The namespace the client is using. `''` is the root namespace. */
21
+ readonly vaultNamespace?: string;
22
+ /** The Cloudflare account the credential resolves to. */
23
+ readonly cloudflareAccountId?: string;
24
+ }
25
+
26
+ export interface IdentityMismatch {
27
+ readonly field: keyof Identity;
28
+ readonly expected: string;
29
+ /** `undefined` when the caller did not observe it — which is itself a mismatch. */
30
+ readonly observed: string | undefined;
31
+ }
32
+
33
+ export interface ExpectedIdentityOptions {
34
+ /** The account alias the plan targets. Omit to leave the account out of the compare. */
35
+ readonly account?: string;
36
+ }
37
+
38
+ /** What the site says the live systems are. Throws `unknown-key` for an unknown alias. */
39
+ export function expectedIdentity(site: Site, options: ExpectedIdentityOptions = {}): Identity {
40
+ const account =
41
+ options.account === undefined
42
+ ? undefined
43
+ : lookup('cloudflare account', site.cloudflare.accounts, options.account).id;
44
+ return {
45
+ vaultClusterName: site.vault.clusterName,
46
+ vaultNamespace: site.vault.namespace,
47
+ ...(account === undefined ? {} : { cloudflareAccountId: account }),
48
+ };
49
+ }
50
+
51
+ const FIELDS = ['vaultClusterName', 'vaultNamespace', 'cloudflareAccountId'] as const;
52
+
53
+ /**
54
+ * Every field the expectation sets and the observation does not match.
55
+ * ⛔ An expected field the caller did not observe COUNTS AS A MISMATCH. "We did not check"
56
+ * must never read the same as "it matched".
57
+ * ⛔ An expectation that sets NO field throws `identity`. Compared field by field, `{}`
58
+ * matches every system there is, so a caller that built it by mistake (a wrong spread, a
59
+ * renamed key) would pass the guard on any vault and any account.
60
+ */
61
+ export function compareIdentity(
62
+ expected: Identity,
63
+ observed: Identity,
64
+ ): readonly IdentityMismatch[] {
65
+ if (FIELDS.every((field) => expected[field] === undefined)) {
66
+ throw new SiteError(
67
+ 'identity',
68
+ 'refusing to plan: the expected identity names no field, so it would match any system',
69
+ );
70
+ }
71
+ const mismatches: IdentityMismatch[] = [];
72
+ for (const field of FIELDS) {
73
+ const want = expected[field];
74
+ if (want === undefined) continue;
75
+ const got = observed[field];
76
+ if (got !== want) mismatches.push({ field, expected: want, observed: got });
77
+ }
78
+ return mismatches;
79
+ }
80
+
81
+ /** {@link compareIdentity}, throwing `identity` with one line per mismatch. */
82
+ export function assertIdentity(expected: Identity, observed: Identity): void {
83
+ const mismatches = compareIdentity(expected, observed);
84
+ if (mismatches.length === 0) return;
85
+ throw new SiteError(
86
+ 'identity',
87
+ 'refusing to plan: the live system is not the one this site describes',
88
+ mismatches.map(
89
+ (m) =>
90
+ `${m.field}: expected "${m.expected}", observed ${m.observed === undefined ? 'nothing' : `"${m.observed}"`}`,
91
+ ),
92
+ );
93
+ }
package/src/index.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @homeflare/site — one typed site config, and every name derived from it.
3
+ *
4
+ * ★ WHY. A public stack that types a hostname cannot be run by anyone else, and a stack
5
+ * that re-derives a name its own way drifts from its siblings. Here the site file holds
6
+ * the base values once; `derive()` builds every hostname, address and mount name the
7
+ * same way in every repo; and names that must never follow a rule are PINNED.
8
+ *
9
+ * import { decodeSite, derive } from '@homeflare/site'; // runtime-neutral
10
+ * import { loadSite } from '@homeflare/site/load'; // Node / Bun: HF_SITE_FILE
11
+ *
12
+ * ⛔ RUNTIME-NEUTRAL. Nothing reachable from this file touches `node:*`, `bun:*`, the
13
+ * filesystem or the environment. File and env reading live in `./load.ts`.
14
+ */
15
+ export { VERSION } from './version.ts';
16
+ export { SiteError, type SiteErrorCode } from './errors.ts';
17
+ export { SITE_FORMAT, type Site, type SiteInput, SiteSchema } from './schema.ts';
18
+ export { type ValidateOptions, decodeSite, validateSite } from './decode.ts';
19
+ export {
20
+ type Derived,
21
+ type DerivedAccess,
22
+ type DerivedHost,
23
+ type DerivedVault,
24
+ derive,
25
+ } from './derive.ts';
26
+ export { type Pins, pins } from './pins.ts';
27
+ export { inventory, pinnedPrincipalIssues, unknownPrincipals } from './inventory.ts';
28
+ export { type Stage, assertStage, checkDeriveVersion } from './guards.ts';
29
+ export {
30
+ type ExpectedIdentityOptions,
31
+ type Identity,
32
+ type IdentityMismatch,
33
+ assertIdentity,
34
+ compareIdentity,
35
+ expectedIdentity,
36
+ } from './identity.ts';
37
+ export {
38
+ SITE_TOKENS,
39
+ type SiteToken,
40
+ type ValuedToken,
41
+ renderTokens,
42
+ tokenValues,
43
+ } from './tokens.ts';
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The inventory: every name and address the site declares for a machine.
3
+ *
4
+ * ★ PINNED LISTS ARE CHECKED AGAINST IT, NOT GENERATED FROM IT. A principal list is a
5
+ * subset of the inventory — an assertion a consumer's test makes — never a rendering of
6
+ * it. The difference matters on the bad day: when a leg is renamed, a generated list
7
+ * silently drops the old address, and the break-glass path goes with it (measured on
8
+ * this estate 2026-08-12: "name is not a listed principal" on the only way in).
9
+ */
10
+ import { joinPath } from './decode.ts';
11
+ import { derive } from './derive.ts';
12
+ import type { Site } from './schema.ts';
13
+
14
+ /** Apex, zones, the vault's names, and every host key, alias, FQDN and leg address. */
15
+ export function inventory(site: Site): ReadonlySet<string> {
16
+ const derived = derive(site);
17
+ const names = new Set<string>([
18
+ site.apex,
19
+ ...Object.values(derived.zones),
20
+ derived.vault.host,
21
+ derived.vault.apiHost,
22
+ site.vault.meshAddress,
23
+ ]);
24
+ for (const [key, host] of Object.entries(site.hosts)) {
25
+ names.add(key);
26
+ for (const alias of host.aliases ?? []) names.add(alias);
27
+ const built = derived.hosts[key];
28
+ if (built?.fqdn !== undefined) names.add(built.fqdn);
29
+ for (const address of Object.values(built?.addresses ?? {})) names.add(address);
30
+ }
31
+ return names;
32
+ }
33
+
34
+ /** The entries of `list` the inventory does not know. Empty means `list` is a subset. */
35
+ export function unknownPrincipals(site: Site, list: readonly string[]): readonly string[] {
36
+ const known = inventory(site);
37
+ return list.filter((entry) => !known.has(entry));
38
+ }
39
+
40
+ /**
41
+ * Every pinned SSH principal the inventory does not know, as `path: entry` lines.
42
+ * ⚠️ A consumer TEST calls this; loading does not. A stale principal is a finding to fix
43
+ * in review, not a reason to refuse a break-glass plan at 3am.
44
+ */
45
+ export function pinnedPrincipalIssues(site: Site): readonly string[] {
46
+ const issues: string[] = [];
47
+ for (const [key, list] of Object.entries(site.pinned.sshPrincipals)) {
48
+ for (const entry of unknownPrincipals(site, list)) {
49
+ // ★ Bracketed like decode errors: `ssh-host.host` is one key, not two levels.
50
+ const path = joinPath(['pinned', 'sshPrincipals', key]);
51
+ issues.push(`${path}: "${entry}" is not in the inventory`);
52
+ }
53
+ }
54
+ return issues;
55
+ }
package/src/load.ts ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * `@homeflare/site/load` — read the site file named by `HF_SITE_FILE`.
3
+ *
4
+ * ⛔ NODE / BUN ONLY: this subpath reads a file and spawns git. Workers use `decodeSite`
5
+ * from the main entry with JSON they already hold.
6
+ *
7
+ * ⛔ NO DEFAULT PATH, AND NO DEFAULT SITE. A missing `HF_SITE_FILE` refuses, naming the
8
+ * example. A fallback would plan someone's stack against whatever file happened to be
9
+ * there — or against the example's placeholder account.
10
+ *
11
+ * ★ PLAIN JSON, not JSONC: Nix, jq and Python read the same file, and none of them read
12
+ * comments.
13
+ */
14
+ import { readFile } from 'node:fs/promises';
15
+ import { resolve } from 'node:path';
16
+ import * as Cause from 'effect/Cause';
17
+ import * as Config from 'effect/Config';
18
+ import * as ConfigProvider from 'effect/ConfigProvider';
19
+ import * as Effect from 'effect/Effect';
20
+ import * as Exit from 'effect/Exit';
21
+ import * as Schema from 'effect/Schema';
22
+ import { checkoutProblem, readCheckout } from './checkout.ts';
23
+ import { decodeStrict, formatIssues, validateSite } from './decode.ts';
24
+ import { SiteError } from './errors.ts';
25
+ import { ENV_OVERRIDES, GUARD_FIELDS, SITE_FILE_VAR, pickOverrides } from './overrides.ts';
26
+ import { type Site, SiteSchema } from './schema.ts';
27
+
28
+ export { ENV_OVERRIDES, SITE_FILE_VAR } from './overrides.ts';
29
+ export { type CheckoutState, checkoutProblem, readCheckout } from './checkout.ts';
30
+
31
+ /** Where the refusal points people. Shipped in the tarball and exported by path. */
32
+ export const SITE_EXAMPLE = 'node_modules/@homeflare/site/site.example.json';
33
+
34
+ export interface LoadSiteOptions {
35
+ /** Defaults to `process.env`. */
36
+ readonly env?: Readonly<Record<string, string | undefined>>;
37
+ /** Resolves a relative `HF_SITE_FILE`. Defaults to `process.cwd()`. */
38
+ readonly cwd?: string;
39
+ /**
40
+ * Skip the committed-on-`main` check and accept `HF_SITE_*` overrides — what a
41
+ * consumer's `--site-dev` flag sets. Without it, any accepted override refuses.
42
+ */
43
+ readonly siteDev?: boolean;
44
+ /** The branch a reviewed site file lives on. Defaults to `main`. */
45
+ readonly branch?: string;
46
+ /** Test hook for the derive-version refusal. Defaults to the installed version. */
47
+ readonly installed?: string;
48
+ }
49
+
50
+ export interface LoadedSite {
51
+ readonly site: Site;
52
+ /** The absolute path that was read. */
53
+ readonly file: string;
54
+ /** Which `HF_SITE_*` overrides changed the file's values. Print these; they are silent otherwise. */
55
+ readonly overrides: readonly string[];
56
+ }
57
+
58
+ const SiteConfig = Config.schema(SiteSchema, 'site');
59
+
60
+ /**
61
+ * ⚠️ `nested('hf')` MUST COME BEFORE `constantCase`. Measured 2026-09-21 on effect
62
+ * 4.0.0-rc.115: each transformation sees the path the previous one produced, so the
63
+ * other order upper-cases `site.apex` to `SITE_APEX` and THEN prefixes a lower-case
64
+ * `hf` — the provider looks for `hf_SITE_APEX`, finds nothing, and the file's value
65
+ * wins. `HF_SITE_APEX` is ignored with no error at all. tests/load.test.ts measures both.
66
+ */
67
+ function envProvider(accepted: Readonly<Record<string, string>>): ConfigProvider.ConfigProvider {
68
+ return ConfigProvider.fromEnv({ env: accepted }).pipe(
69
+ ConfigProvider.nested('hf'),
70
+ ConfigProvider.constantCase,
71
+ );
72
+ }
73
+
74
+ function withOverrides(json: unknown, accepted: Readonly<Record<string, string>>): Site {
75
+ const provider = ConfigProvider.orElse(
76
+ envProvider(accepted),
77
+ ConfigProvider.fromUnknown({ site: json }),
78
+ );
79
+ const exit = Effect.runSyncExit(SiteConfig.parse(provider));
80
+ if (Exit.isSuccess(exit)) return exit.value;
81
+ const error = Cause.squash(exit.cause);
82
+ const cause =
83
+ typeof error === 'object' && error !== null && 'cause' in error ? error.cause : error;
84
+ if (Schema.isSchemaError(cause)) {
85
+ throw new SiteError(
86
+ 'override',
87
+ 'an HF_SITE_* override is not a valid value',
88
+ formatIssues(cause.issue, 'site').map(nameTheVariable),
89
+ );
90
+ }
91
+ throw error;
92
+ }
93
+
94
+ /** `vault.port: …` → `HF_SITE_VAULT_PORT (vault.port): …`, so the fix names what to unset. */
95
+ function nameTheVariable(issue: string): string {
96
+ for (const [name, path] of Object.entries(ENV_OVERRIDES)) {
97
+ const dotted = path.join('.');
98
+ if (issue.startsWith(`${dotted}: `)) return `${name} (${issue}`.replace(': ', '): ');
99
+ }
100
+ return issue;
101
+ }
102
+
103
+ async function readJson(file: string): Promise<unknown> {
104
+ let text: string;
105
+ try {
106
+ text = await readFile(file, 'utf8');
107
+ } catch (error) {
108
+ const reason = error instanceof Error ? error.message : String(error);
109
+ throw new SiteError(
110
+ 'load',
111
+ `${SITE_FILE_VAR} points at ${file}, which cannot be read: ${reason}`,
112
+ );
113
+ }
114
+ try {
115
+ return JSON.parse(text) as unknown;
116
+ } catch (error) {
117
+ const reason = error instanceof Error ? error.message : String(error);
118
+ throw new SiteError(
119
+ 'load',
120
+ `${file} is not plain JSON (${reason}). Site files carry no comments and no trailing commas.`,
121
+ );
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Locate, check, read, decode and validate the site file.
127
+ * Throws {@link SiteError}: `load`, `checkout`, `override`, `decode`, `reference`,
128
+ * `derive-version`.
129
+ */
130
+ export async function loadSite(options: LoadSiteOptions = {}): Promise<LoadedSite> {
131
+ const env = options.env ?? process.env;
132
+ const named = env[SITE_FILE_VAR]?.trim();
133
+ if (named === undefined || named === '') {
134
+ throw new SiteError(
135
+ 'load',
136
+ `${SITE_FILE_VAR} is not set, and there is no default path. Start from the example: ` +
137
+ `copy ${SITE_EXAMPLE}, replace every value, then export ${SITE_FILE_VAR}=<that file>.`,
138
+ );
139
+ }
140
+ const file = resolve(options.cwd ?? process.cwd(), named);
141
+ // ★ Read before the checkout check, so a mistyped path says "cannot be read" rather
142
+ // than "not committed" about a file that does not exist.
143
+ const json = await readJson(file);
144
+
145
+ if (options.siteDev !== true) {
146
+ const branch = options.branch ?? 'main';
147
+ const problem = checkoutProblem(await readCheckout(file), branch);
148
+ if (problem !== undefined) {
149
+ throw new SiteError(
150
+ 'checkout',
151
+ `refusing ${file}: ${problem}. Live values come from a reviewed "${branch}"; ` +
152
+ `pass --site-dev (siteDev: true) to load it anyway.`,
153
+ );
154
+ }
155
+ }
156
+
157
+ // ★ The file is decoded ALONE first, strictly, so its own errors name file paths and a
158
+ // misspelt key is refused before any override can paper over it.
159
+ const fromFile = decodeStrict(json);
160
+
161
+ const { accepted, refused } = pickOverrides(env);
162
+ if (refused.length > 0) {
163
+ throw new SiteError(
164
+ 'override',
165
+ 'unsupported HF_SITE_* variables; edit the site file instead',
166
+ refused.map(
167
+ (name) =>
168
+ `${name}: not in ENV_OVERRIDES — records, lists and guard fields ` +
169
+ `(${GUARD_FIELDS.join(', ')}) are never overridable`,
170
+ ),
171
+ );
172
+ }
173
+ const names = Object.keys(accepted);
174
+ // ⛔ An override is an unreviewed value: same switch as the checkout guard (overrides.ts).
175
+ if (names.length > 0 && options.siteDev !== true) {
176
+ throw new SiteError(
177
+ 'override',
178
+ 'HF_SITE_* overrides change reviewed values, so they need --site-dev (siteDev: true); ' +
179
+ 'unset them, or edit the site file on a branch',
180
+ names,
181
+ );
182
+ }
183
+ const site = names.length === 0 ? fromFile : withOverrides(json, accepted);
184
+
185
+ return { site: validateSite(site, options), file, overrides: names };
186
+ }
package/src/net.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * IPv4 arithmetic for leg addresses: a network's base plus a host number.
3
+ *
4
+ * ★ WHY A HOST NUMBER AND NOT A LAST OCTET. On a /24 they are the same thing, and that is
5
+ * the common case. On a /23 or a /16 a "last octet" is ambiguous, while "the 11th
6
+ * address after the network address" is not. The /24 reading stays exact.
7
+ */
8
+ import { parseCidr } from './primitives.ts';
9
+
10
+ function dotted(value: number): string {
11
+ return [24, 16, 8, 0].map((shift) => Math.floor(value / 2 ** shift) % 256).join('.');
12
+ }
13
+
14
+ /** The largest usable host number on a network (the broadcast address is excluded). */
15
+ export function lastHostNumber(cidr: string): number {
16
+ const parsed = parseCidr(cidr);
17
+ if (parsed === undefined) throw new Error(`not an IPv4 network: ${cidr}`);
18
+ return 2 ** (32 - parsed.prefix) - 2;
19
+ }
20
+
21
+ /** `addressOn('192.0.2.0/24', 11)` is `192.0.2.11`. */
22
+ export function addressOn(cidr: string, hostNumber: number): string {
23
+ const parsed = parseCidr(cidr);
24
+ if (parsed === undefined) throw new Error(`not an IPv4 network: ${cidr}`);
25
+ if (hostNumber < 1 || hostNumber > lastHostNumber(cidr)) {
26
+ throw new Error(`host number ${hostNumber} does not fit ${cidr}`);
27
+ }
28
+ return dotted(parsed.base + hostNumber);
29
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The environment variables `loadSite` accepts as overrides — and the ONLY ones.
3
+ *
4
+ * ★ WHY A LIST AND NOT "ANY HF_SITE_*". Effect's env provider will happily answer any
5
+ * path, and three of the answers are wrong without an error. Measured 2026-09-21 on
6
+ * effect 4.0.0-rc.115, overriding on top of the file via `ConfigProvider.orElse`:
7
+ * 1. A RECORD or ARRAY is REPLACED, not merged. `HF_SITE_NETWORKS_MGMT` decoded
8
+ * `networks` as `{ "MGMT": … }` — every other network gone, and the key upper-cased.
9
+ * `HF_SITE_ZONES_MGMT` did the same to `zones`, and `…_LIST_0` truncated a list.
10
+ * 2. A name that is another's name plus `_…` SHADOWS it. With `HF_SITE_LABELS_VAULT_API`
11
+ * set, `labels.vault` was read as a record (every `HF_SITE_LABELS_VAULT_*` var) and
12
+ * the decode failed "Expected string". Hence `vault.label` / `vault.apiLabel`.
13
+ * 3. The ORDER of `nested` and `constantCase` decides the prefix: see load.ts.
14
+ * So only scalar leaves under plain structs are listed, and tests/overrides.test.ts
15
+ * proves each one changes exactly its own path and nothing else.
16
+ *
17
+ * ⛔ GUARD FIELDS ARE NEVER OVERRIDABLE: `version`, `deriveVersion`, `kind`,
18
+ * `vault.clusterName` and `vault.namespace`. An exported `HF_SITE_KIND=live` would defeat
19
+ * the stage guard, and an overridden cluster name or namespace would make the identity
20
+ * check compare against itself: a client that takes its namespace from the site would
21
+ * switch namespace AND expectation together, and plan into the other one "matching".
22
+ *
23
+ * ⛔ AND OVERRIDES ARE DEV-ONLY. An override is an unreviewed value, exactly like an
24
+ * uncommitted edit to the file, so `loadSite` accepts one only with `siteDev` — the same
25
+ * switch as the checkout guard. A stray `HF_SITE_APEX` left in a shell would otherwise
26
+ * plan a rename of every derived hostname against live state.
27
+ *
28
+ * ⚠️ `HF_` IS ALSO HUGGING FACE'S PREFIX (`HF_TOKEN`, `HF_HOME`). Only `HF_SITE_*` is read,
29
+ * and only the names below ever reach the provider.
30
+ */
31
+
32
+ /** Env var → the site path it overrides. */
33
+ export const ENV_OVERRIDES: Readonly<Record<string, readonly string[]>> = {
34
+ HF_SITE_APEX: ['apex'],
35
+ HF_SITE_VAULT_LABEL: ['vault', 'label'],
36
+ HF_SITE_VAULT_API_LABEL: ['vault', 'apiLabel'],
37
+ HF_SITE_VAULT_PORT: ['vault', 'port'],
38
+ HF_SITE_VAULT_MESH_ADDRESS: ['vault', 'meshAddress'],
39
+ HF_SITE_VAULT_OIDC_MOUNT: ['vault', 'oidcMount'],
40
+ HF_SITE_VAULT_CLI_CALLBACK_PORT: ['vault', 'cliCallbackPort'],
41
+ HF_SITE_VAULT_LAN_HOST: ['vault', 'lan', 'host'],
42
+ HF_SITE_VAULT_LAN_PORT: ['vault', 'lan', 'port'],
43
+ HF_SITE_VAULT_LAN_SCHEME: ['vault', 'lan', 'scheme'],
44
+ HF_SITE_CLOUDFLARE_ACCESS_TEAM: ['cloudflare', 'access', 'team'],
45
+ HF_SITE_GITHUB_OWNER: ['github', 'owner'],
46
+ HF_SITE_PATHS_ESTATE_ROOT: ['paths', 'estateRoot'],
47
+ };
48
+
49
+ /** Site paths no variable may ever set. `tests/overrides.test.ts` holds the list to this. */
50
+ export const GUARD_FIELDS: readonly string[] = [
51
+ 'version',
52
+ 'deriveVersion',
53
+ 'kind',
54
+ 'vault.clusterName',
55
+ 'vault.namespace',
56
+ ];
57
+
58
+ /** Locates the file; read by `loadSite`, never passed to the provider. */
59
+ export const SITE_FILE_VAR = 'HF_SITE_FILE';
60
+
61
+ const PREFIX = 'HF_SITE_';
62
+
63
+ /**
64
+ * The accepted overrides present in `env`, or a refusal naming every rejected variable.
65
+ * Returns the names sorted, so a caller can print exactly what changed the file's values.
66
+ */
67
+ export function pickOverrides(env: Readonly<Record<string, string | undefined>>): {
68
+ readonly accepted: Readonly<Record<string, string>>;
69
+ readonly refused: readonly string[];
70
+ } {
71
+ const accepted: Record<string, string> = {};
72
+ const refused: string[] = [];
73
+ for (const [name, value] of Object.entries(env).sort(([a], [b]) => a.localeCompare(b))) {
74
+ if (!name.startsWith(PREFIX) || name === SITE_FILE_VAR || value === undefined) continue;
75
+ if (Object.hasOwn(ENV_OVERRIDES, name)) accepted[name] = value;
76
+ else refused.push(name);
77
+ }
78
+ return { accepted, refused };
79
+ }
package/src/pins.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Read PINNED values by key. Nothing here is computed; it only refuses unknown keys.
3
+ *
4
+ * ⛔ WHY THESE ARE NOT DERIVED — each is a replace or an outage if it ever follows a rule:
5
+ * - `names`: Workers, D1, KV, R2 and Durable Object names follow no rule, and a derived
6
+ * D1 name REPLACES the database, deleting its data.
7
+ * - `certificates`: hostnames on an adopted certificate. A change reissues it and revokes
8
+ * the one a host is still serving.
9
+ * - `adopted`: ids and names of objects that already exist (an LB pool, a WAF ruleset,
10
+ * Ceph and PBS names, PKI common names). Rendering one from a rule means a new object.
11
+ * - `policies` and `sshPrincipals`: explicit lists. A break-glass principal must never
12
+ * vanish because a network leg was renamed; `unknownPrincipals` checks them against
13
+ * the inventory instead of generating them from it.
14
+ */
15
+ import { lookup } from './errors.ts';
16
+ import type { Site } from './schema.ts';
17
+
18
+ export interface Pins {
19
+ /** A physical resource name, e.g. `pins.name('alerts.d1')`. */
20
+ name(key: string): string;
21
+ /** The hostnames on an adopted certificate. */
22
+ certificate(key: string): readonly string[];
23
+ /** An adopted object's id or name. */
24
+ adopted(key: string): string;
25
+ /** A policy list (emails, groups, CIDRs — whatever the enforcing repo reads). */
26
+ policy(key: string): readonly string[];
27
+ /** An SSH principal list. */
28
+ principals(key: string): readonly string[];
29
+ }
30
+
31
+ export function pins(site: Site): Pins {
32
+ const { pinned } = site;
33
+ return {
34
+ name: (key) => lookup('pinned name', pinned.names, key),
35
+ certificate: (key) => lookup('pinned certificate', pinned.certificates, key),
36
+ adopted: (key) => lookup('adopted id', pinned.adopted, key),
37
+ policy: (key) => lookup('pinned policy list', pinned.policies, key),
38
+ principals: (key) => lookup('pinned principal list', pinned.sshPrincipals, key),
39
+ };
40
+ }