@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.
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/dist/checkout.d.ts +26 -0
- package/dist/checkout.d.ts.map +1 -0
- package/dist/decode.d.ts +26 -0
- package/dist/decode.d.ts.map +1 -0
- package/dist/derive.d.ts +66 -0
- package/dist/derive.d.ts.map +1 -0
- package/dist/errors.d.ts +45 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/guards.d.ts +19 -0
- package/dist/guards.d.ts.map +1 -0
- package/dist/identity.d.ts +33 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/index-dvzn0279.js +288 -0
- package/dist/index-dvzn0279.js.map +17 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +238 -0
- package/dist/index.js.map +14 -0
- package/dist/inventory.d.ts +12 -0
- package/dist/inventory.d.ts.map +1 -0
- package/dist/load.d.ts +34 -0
- package/dist/load.d.ts.map +1 -0
- package/dist/load.js +190 -0
- package/dist/load.js.map +12 -0
- package/dist/net.d.ts +5 -0
- package/dist/net.d.ts.map +1 -0
- package/dist/overrides.d.ts +45 -0
- package/dist/overrides.d.ts.map +1 -0
- package/dist/pins.d.ts +15 -0
- package/dist/pins.d.ts.map +1 -0
- package/dist/primitives.d.ts +57 -0
- package/dist/primitives.d.ts.map +1 -0
- package/dist/references.d.ts +6 -0
- package/dist/references.d.ts.map +1 -0
- package/dist/schema.d.ts +100 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/tokens.d.ts +24 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/package.json +41 -0
- package/site.example.json +81 -0
- package/src/checkout.ts +109 -0
- package/src/decode.ts +99 -0
- package/src/derive.ts +188 -0
- package/src/errors.ts +60 -0
- package/src/guards.ts +42 -0
- package/src/identity.ts +93 -0
- package/src/index.ts +43 -0
- package/src/inventory.ts +55 -0
- package/src/load.ts +186 -0
- package/src/net.ts +29 -0
- package/src/overrides.ts +79 -0
- package/src/pins.ts +40 -0
- package/src/primitives.ts +139 -0
- package/src/references.ts +82 -0
- package/src/schema.ts +144 -0
- package/src/tokens.ts +60 -0
- package/src/version.ts +2 -0
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import * as Schema from 'effect/Schema';
|
|
2
|
+
/** The site file format this package reads. A new format is a new literal, never a reuse. */
|
|
3
|
+
export declare const SITE_FORMAT = 1;
|
|
4
|
+
export declare const SiteSchema: Schema.Struct<{
|
|
5
|
+
/** The file format. Only {@link SITE_FORMAT} decodes. */
|
|
6
|
+
readonly version: Schema.Literal<1>;
|
|
7
|
+
/**
|
|
8
|
+
* The `@homeflare/site` version whose derive rules this file was reviewed against.
|
|
9
|
+
* ⛔ A repo refuses to load when its installed package differs (see guards.ts), so a
|
|
10
|
+
* derive change can never rename live objects without someone bumping this line.
|
|
11
|
+
*/
|
|
12
|
+
readonly deriveVersion: Schema.String;
|
|
13
|
+
/** ⛔ Only a `live` site may plan stage `live` (see guards.ts). */
|
|
14
|
+
readonly kind: Schema.Literals<readonly ["live", "testing", "example"]>;
|
|
15
|
+
readonly apex: Schema.String;
|
|
16
|
+
/**
|
|
17
|
+
* Zone suffixes under the apex: `{ "mgmt": "mgmt" }` gives `mgmt.<apex>`.
|
|
18
|
+
* ⛔ `mgmt` is required (it is `<mgmt-zone>`), and the key `apex` is reserved.
|
|
19
|
+
*/
|
|
20
|
+
readonly zones: Schema.StructWithRest<Schema.Struct<{
|
|
21
|
+
readonly mgmt: Schema.String;
|
|
22
|
+
}>, readonly [Schema.$Record<Schema.String, Schema.String>]>;
|
|
23
|
+
readonly networks: Schema.$Record<Schema.String, Schema.String>;
|
|
24
|
+
readonly hosts: Schema.$Record<Schema.String, Schema.Struct<{
|
|
25
|
+
readonly zone: Schema.optionalKey<Schema.String>;
|
|
26
|
+
readonly aliases: Schema.optionalKey<Schema.$Array<Schema.String>>;
|
|
27
|
+
readonly legs: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Number>>;
|
|
28
|
+
}>>;
|
|
29
|
+
readonly clusters: Schema.withDecodingDefaultKey<Schema.$Record<Schema.String, Schema.Struct<{
|
|
30
|
+
readonly members: Schema.$Array<Schema.String>;
|
|
31
|
+
}>>, never>;
|
|
32
|
+
readonly products: Schema.withDecodingDefaultKey<Schema.$Record<Schema.String, Schema.Struct<{
|
|
33
|
+
readonly label: Schema.optionalKey<Schema.String>;
|
|
34
|
+
readonly domain: Schema.optionalKey<Schema.String>;
|
|
35
|
+
}>>, never>;
|
|
36
|
+
readonly services: Schema.withDecodingDefaultKey<Schema.$Record<Schema.String, Schema.Struct<{
|
|
37
|
+
readonly host: Schema.String;
|
|
38
|
+
readonly port: Schema.Number;
|
|
39
|
+
readonly scheme: Schema.Literals<readonly ["http", "https"]>;
|
|
40
|
+
}>>, never>;
|
|
41
|
+
readonly cloudflare: Schema.Struct<{
|
|
42
|
+
readonly accounts: Schema.$Record<Schema.String, Schema.Struct<{
|
|
43
|
+
readonly id: Schema.String;
|
|
44
|
+
readonly zones: Schema.$Array<Schema.String>;
|
|
45
|
+
/** Each surface becomes an OpenBao mount `cloudflare-<alias>-<surface>`. */
|
|
46
|
+
readonly surfaces: Schema.$Array<Schema.String>;
|
|
47
|
+
}>>;
|
|
48
|
+
readonly access: Schema.Struct<{
|
|
49
|
+
readonly team: Schema.String;
|
|
50
|
+
}>;
|
|
51
|
+
}>;
|
|
52
|
+
readonly github: Schema.Struct<{
|
|
53
|
+
readonly owner: Schema.String;
|
|
54
|
+
}>;
|
|
55
|
+
readonly paths: Schema.Struct<{
|
|
56
|
+
readonly estateRoot: Schema.String;
|
|
57
|
+
}>;
|
|
58
|
+
readonly vault: Schema.Struct<{
|
|
59
|
+
/** `<vault-host>` = `<label>.<apex>`. */
|
|
60
|
+
readonly label: Schema.String;
|
|
61
|
+
/**
|
|
62
|
+
* `<vault-api-host>` = `<apiLabel>.<vault-host>`.
|
|
63
|
+
* ⚠️ NOT `vaultApi` beside a `vault` label. Measured 2026-09-21 on effect 4.0.0-rc.115:
|
|
64
|
+
* constant-cased env names nest by prefix, so `HF_SITE_VAULT_API` made the provider
|
|
65
|
+
* read `vault` as a record and the whole decode failed "Expected string". No field
|
|
66
|
+
* name here may be another's name plus `_…`; tests/overrides.test.ts asserts it.
|
|
67
|
+
*/
|
|
68
|
+
readonly apiLabel: Schema.String;
|
|
69
|
+
/**
|
|
70
|
+
* ⛔ PINNED IDENTITY: the `cluster_name` the vault reports on `sys/health`. Set at init,
|
|
71
|
+
* never derived; plans compare it before touching anything (see identity.ts).
|
|
72
|
+
*/
|
|
73
|
+
readonly clusterName: Schema.String;
|
|
74
|
+
readonly namespace: Schema.withDecodingDefaultKey<Schema.String, never>;
|
|
75
|
+
readonly port: Schema.withDecodingDefaultKey<Schema.Number, never>;
|
|
76
|
+
/** The vault's address on the private Mesh path. Machines use it; strict TLS applies. */
|
|
77
|
+
readonly meshAddress: Schema.String;
|
|
78
|
+
/** The LAN pass-through proxy: a declared host, its port, and its scheme. */
|
|
79
|
+
readonly lan: Schema.Struct<{
|
|
80
|
+
readonly host: Schema.String;
|
|
81
|
+
readonly port: Schema.Number;
|
|
82
|
+
readonly scheme: Schema.Literals<readonly ["http", "https"]>;
|
|
83
|
+
}>;
|
|
84
|
+
readonly oidcMount: Schema.withDecodingDefaultKey<Schema.String, never>;
|
|
85
|
+
/** The port `bao login -method=oidc` listens on for its localhost callback. */
|
|
86
|
+
readonly cliCallbackPort: Schema.withDecodingDefaultKey<Schema.Number, never>;
|
|
87
|
+
}>;
|
|
88
|
+
readonly pinned: Schema.withDecodingDefaultKey<Schema.Struct<{
|
|
89
|
+
readonly names: Schema.withDecodingDefaultKey<Schema.$Record<Schema.String, Schema.String>, never>;
|
|
90
|
+
readonly certificates: Schema.withDecodingDefaultKey<Schema.$Record<Schema.String, Schema.NonEmptyArray<Schema.String>>, never>;
|
|
91
|
+
readonly adopted: Schema.withDecodingDefaultKey<Schema.$Record<Schema.String, Schema.String>, never>;
|
|
92
|
+
readonly policies: Schema.withDecodingDefaultKey<Schema.$Record<Schema.String, Schema.$Array<Schema.String>>, never>;
|
|
93
|
+
readonly sshPrincipals: Schema.withDecodingDefaultKey<Schema.$Record<Schema.String, Schema.$Array<Schema.String>>, never>;
|
|
94
|
+
}>, never>;
|
|
95
|
+
}>;
|
|
96
|
+
/** A decoded site: defaults applied, every value checked. */
|
|
97
|
+
export type Site = typeof SiteSchema.Type;
|
|
98
|
+
/** What a site file may contain before defaults are applied. */
|
|
99
|
+
export type SiteInput = typeof SiteSchema.Encoded;
|
|
100
|
+
//# sourceMappingURL=schema.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAmBxC,6FAA6F;AAC7F,eAAO,MAAM,WAAW,IAAI,CAAC;AAqE7B,eAAO,MAAM,UAAU;IACrB,yDAAyD;;IAEzD;;;;OAIG;;IAEH,kEAAkE;;;IAGlE;;;OAGG;;;;;;;;;;;;;;;;;;;;;;;;;;YAtDH,4EAA4E;;;;;;;;;;;;;;QAK5E,yCAAyC;;QAEzC;;;;;;WAMG;;QAEH;;;WAGG;;;;QAIH,yFAAyF;;QAEzF,6EAA6E;;;;;;;QAG7E,+EAA+E;;;;;;;;;;EA0C/E,CAAC;AAEH,6DAA6D;AAC7D,MAAM,MAAM,IAAI,GAAG,OAAO,UAAU,CAAC,IAAI,CAAC;AAC1C,gEAAgE;AAChE,MAAM,MAAM,SAAS,GAAG,OAAO,UAAU,CAAC,OAAO,CAAC"}
|
package/dist/tokens.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Site } from './schema.ts';
|
|
2
|
+
export declare const SITE_TOKENS: {
|
|
3
|
+
readonly '<apex>': 'the apex domain every derived name hangs off';
|
|
4
|
+
readonly '<vault-host>': 'the vault UI hostname: <vault label>.<apex>';
|
|
5
|
+
readonly '<vault-api-host>': 'the vault public API hostname: <api label>.<vault-host>';
|
|
6
|
+
readonly '<mgmt-zone>': 'the management zone: <mgmt label>.<apex>';
|
|
7
|
+
readonly '<access-team>': 'the Cloudflare Access team name (<access-team>.cloudflareaccess.com)';
|
|
8
|
+
readonly '<github-owner>': 'the GitHub user or organisation that owns the repos';
|
|
9
|
+
readonly '<estate-root>': 'the absolute directory holding host-local estate files';
|
|
10
|
+
readonly '<cluster>': 'any key of site.clusters — a variable, never substituted';
|
|
11
|
+
};
|
|
12
|
+
export type SiteToken = keyof typeof SITE_TOKENS;
|
|
13
|
+
/** Tokens with exactly one value per site. */
|
|
14
|
+
export type ValuedToken = Exclude<SiteToken, '<cluster>'>;
|
|
15
|
+
/** Each single-valued token's value for `site`. */
|
|
16
|
+
export declare function tokenValues(site: Site): Readonly<Record<ValuedToken, string>>;
|
|
17
|
+
/**
|
|
18
|
+
* Replace every single-valued token in `text` with its value for `site`.
|
|
19
|
+
* ★ ONE PASS, longest token first in the alternation. A value is never read again, so a
|
|
20
|
+
* value that itself contains a token stays literal. ⚠️ Measured 2026-09-21: the earlier
|
|
21
|
+
* token-by-token loop rendered an estate root of `/opt/<apex>` as `/opt/example.com`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function renderTokens(text: string, site: Site): string;
|
|
24
|
+
//# sourceMappingURL=tokens.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../src/tokens.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AAExC,eAAO,MAAM,WAAW;aACtB,QAAQ,EAAE,8CAA8C;aACxD,cAAc,EAAE,6CAA6C;aAC7D,kBAAkB,EAAE,yDAAyD;aAC7E,aAAa,EAAE,0CAA0C;aACzD,eAAe,EAAE,sEAAsE;aACvF,gBAAgB,EAAE,qDAAqD;aACvE,eAAe,EAAE,wDAAwD;aACzE,WAAW,EAAE,0DAA0D;CAC/D,CAAC;AAEX,MAAM,MAAM,SAAS,GAAG,MAAM,OAAO,WAAW,CAAC;AAEjD,8CAA8C;AAC9C,MAAM,MAAM,WAAW,GAAG,OAAO,CAAC,SAAS,EAAE,WAAW,CAAC,CAAC;AAE1D,mDAAmD;AACnD,wBAAgB,WAAW,CAAC,IAAI,EAAE,IAAI,GAAG,QAAQ,CAAC,MAAM,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC,CAW7E;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,GAAG,MAAM,CAQ7D"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,OAAO,EAAE,MAAgB,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@homeflare/site",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "One typed site config: base values, pinned names, and every hostname and address derived from them.",
|
|
5
|
+
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/site#readme",
|
|
6
|
+
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "Timothy Schneider",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/taslabs-net/homeflare-kit.git",
|
|
12
|
+
"directory": "packages/site"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"src",
|
|
17
|
+
"site.example.json",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./load": {
|
|
29
|
+
"types": "./dist/load.d.ts",
|
|
30
|
+
"default": "./dist/load.js"
|
|
31
|
+
},
|
|
32
|
+
"./site.example.json": "./site.example.json",
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"effect": "4.0.0-rc.115"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"deriveVersion": "0.1.0",
|
|
4
|
+
"kind": "example",
|
|
5
|
+
"apex": "example.com",
|
|
6
|
+
"zones": { "mgmt": "mgmt", "lab": "lab" },
|
|
7
|
+
"networks": {
|
|
8
|
+
"mgmt": "192.0.2.0/24",
|
|
9
|
+
"lab": "198.51.100.0/24",
|
|
10
|
+
"services": "203.0.113.0/24",
|
|
11
|
+
"storage": "198.18.4.0/24"
|
|
12
|
+
},
|
|
13
|
+
"hosts": {
|
|
14
|
+
"hub": {
|
|
15
|
+
"zone": "mgmt",
|
|
16
|
+
"aliases": ["gateway"],
|
|
17
|
+
"legs": { "mgmt": 250, "services": 250 }
|
|
18
|
+
},
|
|
19
|
+
"n1": { "zone": "mgmt", "legs": { "mgmt": 11, "lab": 11 } },
|
|
20
|
+
"n2": { "zone": "mgmt", "legs": { "mgmt": 12, "lab": 12 } },
|
|
21
|
+
"n3": { "zone": "mgmt", "legs": { "mgmt": 13, "lab": 13 } },
|
|
22
|
+
"backup": { "zone": "lab", "legs": { "mgmt": 7, "storage": 7 } },
|
|
23
|
+
"docs": { "zone": "apex" }
|
|
24
|
+
},
|
|
25
|
+
"clusters": { "c1": { "members": ["n1", "n2", "n3"] } },
|
|
26
|
+
"products": {
|
|
27
|
+
"alerts": {},
|
|
28
|
+
"wiki": { "label": "kb" },
|
|
29
|
+
"shop": { "domain": "shop.example.net" }
|
|
30
|
+
},
|
|
31
|
+
"services": {
|
|
32
|
+
"grafana": { "host": "hub", "port": 3000, "scheme": "http" }
|
|
33
|
+
},
|
|
34
|
+
"cloudflare": {
|
|
35
|
+
"accounts": {
|
|
36
|
+
"main": {
|
|
37
|
+
"id": "00000000000000000000000000000001",
|
|
38
|
+
"zones": ["example.com", "example.org"],
|
|
39
|
+
"surfaces": ["dns", "platform", "security", "access"]
|
|
40
|
+
},
|
|
41
|
+
"lab": {
|
|
42
|
+
"id": "00000000000000000000000000000002",
|
|
43
|
+
"zones": ["example.net"],
|
|
44
|
+
"surfaces": ["dns"]
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"access": { "team": "example-team" }
|
|
48
|
+
},
|
|
49
|
+
"github": { "owner": "example-org" },
|
|
50
|
+
"paths": { "estateRoot": "/opt/example" },
|
|
51
|
+
"vault": {
|
|
52
|
+
"label": "v",
|
|
53
|
+
"apiLabel": "api",
|
|
54
|
+
"clusterName": "example-vault",
|
|
55
|
+
"meshAddress": "198.18.0.2",
|
|
56
|
+
"lan": { "host": "hub", "port": 8200, "scheme": "http" }
|
|
57
|
+
},
|
|
58
|
+
"pinned": {
|
|
59
|
+
"names": { "alerts.d1": "example-alerts", "alerts.worker": "example-alerts-api" },
|
|
60
|
+
"certificates": { "vault.origin": ["v.example.com", "api.v.example.com"] },
|
|
61
|
+
"adopted": {
|
|
62
|
+
"access.admin-policy": "00000000-0000-4000-8000-000000000001",
|
|
63
|
+
"backup.datastore": "example-store"
|
|
64
|
+
},
|
|
65
|
+
"policies": { "access.admins": ["alice@example.com"] },
|
|
66
|
+
"sshPrincipals": {
|
|
67
|
+
"ssh-host.host": [
|
|
68
|
+
"example.com",
|
|
69
|
+
"mgmt.example.com",
|
|
70
|
+
"hub",
|
|
71
|
+
"gateway",
|
|
72
|
+
"192.0.2.250",
|
|
73
|
+
"n1",
|
|
74
|
+
"n2",
|
|
75
|
+
"n3",
|
|
76
|
+
"192.0.2.11",
|
|
77
|
+
"198.51.100.11"
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
package/src/checkout.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is the site file committed, unmodified, on `main`?
|
|
3
|
+
*
|
|
4
|
+
* ⛔ NODE-ONLY (spawns git). Imported by `@homeflare/site/load`, never by the main entry.
|
|
5
|
+
*
|
|
6
|
+
* ★ WHY THE LOADER ASKS. Live plans must run on reviewed values. A site file edited in a
|
|
7
|
+
* working tree, or read from a feature branch, would plan whatever it says — and a plan
|
|
8
|
+
* against live state is where an unreviewed rename becomes a replace. `siteDev` (the
|
|
9
|
+
* `--site-dev` flag in a consumer's CLI) is the explicit, visible way around it.
|
|
10
|
+
*
|
|
11
|
+
* ★ DIRTINESS IS THE FILE'S OWN, not the whole checkout's. Unrelated uncommitted work in
|
|
12
|
+
* the repo holding the site file says nothing about the values being loaded, and would
|
|
13
|
+
* otherwise block every deploy while someone edits a doc.
|
|
14
|
+
*/
|
|
15
|
+
import { execFile } from 'node:child_process';
|
|
16
|
+
import { realpath } from 'node:fs/promises';
|
|
17
|
+
import { basename, dirname } from 'node:path';
|
|
18
|
+
import { promisify } from 'node:util';
|
|
19
|
+
|
|
20
|
+
const run = promisify(execFile);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* ⚠️ GIT_* FROM THE CALLER'S ENVIRONMENT IS DROPPED. Git exports `GIT_DIR` (and friends)
|
|
24
|
+
* to hooks, and `-C <dir>` does not override an exported `GIT_DIR`. Measured 2026-09-21:
|
|
25
|
+
* `GIT_DIR=<some repo>/.git git -C <a directory outside any repo> symbolic-ref HEAD`
|
|
26
|
+
* printed that other repo's branch. A loader run from a pre-push hook would ask about
|
|
27
|
+
* the HOOK's repository, and answer "clean, on main" for a file it never looked at.
|
|
28
|
+
* ★ `GIT_LITERAL_PATHSPECS=1` is the one git variable set back: the file name is a name,
|
|
29
|
+
* never a glob. Without it `site[1].json` would match a tracked `site1.json`.
|
|
30
|
+
*/
|
|
31
|
+
function gitEnv(): NodeJS.ProcessEnv {
|
|
32
|
+
return {
|
|
33
|
+
...Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_'))),
|
|
34
|
+
GIT_LITERAL_PATHSPECS: '1',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CheckoutState {
|
|
39
|
+
/** The checked-out branch, or `undefined` on a detached HEAD. */
|
|
40
|
+
readonly branch: string | undefined;
|
|
41
|
+
/** Git knows the file (⚠️ an ignored or untracked file was never reviewed). */
|
|
42
|
+
readonly tracked: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* The file's bytes differ from HEAD, staged or not.
|
|
45
|
+
* ⚠️ Compared by BLOB HASH, not only by `git status`. Measured 2026-09-21: after
|
|
46
|
+
* `git update-index --skip-worktree` (the usual way to keep a local config tweak),
|
|
47
|
+
* `status --porcelain` printed nothing for an edited site file, and a status-only
|
|
48
|
+
* guard loaded the edited apex as "clean, on main".
|
|
49
|
+
*/
|
|
50
|
+
readonly dirty: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function git(dir: string, args: readonly string[]): Promise<string | undefined> {
|
|
54
|
+
try {
|
|
55
|
+
const { stdout } = await run('git', ['-C', dir, ...args], { encoding: 'utf8', env: gitEnv() });
|
|
56
|
+
return stdout.trim();
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `realpath`, or the path unchanged when it cannot be resolved (the git calls then fail). */
|
|
63
|
+
async function resolved(file: string): Promise<string> {
|
|
64
|
+
try {
|
|
65
|
+
return await realpath(file);
|
|
66
|
+
} catch {
|
|
67
|
+
return file;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The file's checkout state, or `undefined` when it is not inside a git work tree.
|
|
73
|
+
*
|
|
74
|
+
* ⚠️ THE REAL FILE IS CHECKED, NOT THE LINK. Measured 2026-09-21: a committed symlink whose
|
|
75
|
+
* target lived outside the repository passed as "clean, on main" while the target was
|
|
76
|
+
* edited freely — git tracks the link's target PATH, not the bytes behind it. Resolving
|
|
77
|
+
* first makes that target "not inside a git checkout", which refuses.
|
|
78
|
+
*/
|
|
79
|
+
export async function readCheckout(file: string): Promise<CheckoutState | undefined> {
|
|
80
|
+
const real = await resolved(file);
|
|
81
|
+
const dir = dirname(real);
|
|
82
|
+
const name = basename(real);
|
|
83
|
+
if ((await git(dir, ['rev-parse', '--is-inside-work-tree'])) !== 'true') return undefined;
|
|
84
|
+
// ★ symbolic-ref, not `rev-parse --abbrev-ref`: it answers on an unborn branch too, and
|
|
85
|
+
// fails (→ undefined) on a detached HEAD instead of printing the word "HEAD".
|
|
86
|
+
const branch = await git(dir, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
|
|
87
|
+
const tracked = (await git(dir, ['ls-files', '--error-unmatch', '--', name])) !== undefined;
|
|
88
|
+
const status = await git(dir, ['status', '--porcelain', '--', name]);
|
|
89
|
+
// ★ hash-object applies the path's clean filters, so it names the blob git WOULD store;
|
|
90
|
+
// HEAD:./<name> is the blob that was committed. Either failing counts as dirty.
|
|
91
|
+
const onDisk = await git(dir, ['hash-object', '--', name]);
|
|
92
|
+
const committed = await git(dir, ['rev-parse', '--verify', '--quiet', `HEAD:./${name}`]);
|
|
93
|
+
const differs = onDisk === undefined || committed === undefined || onDisk !== committed;
|
|
94
|
+
return { branch, tracked, dirty: status === undefined || status !== '' || differs };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Why `state` is not a reviewed checkout of `branch`, or `undefined` when it is. */
|
|
98
|
+
export function checkoutProblem(
|
|
99
|
+
state: CheckoutState | undefined,
|
|
100
|
+
branch: string,
|
|
101
|
+
): string | undefined {
|
|
102
|
+
if (state === undefined) return 'it is not inside a git checkout';
|
|
103
|
+
if (!state.tracked) return 'it is not committed (untracked or ignored)';
|
|
104
|
+
if (state.dirty) return 'it has uncommitted changes';
|
|
105
|
+
if (state.branch !== branch) {
|
|
106
|
+
return `the checkout is on ${state.branch === undefined ? 'a detached HEAD' : `"${state.branch}"`}, not "${branch}"`;
|
|
107
|
+
}
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
package/src/decode.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decode a site from plain data. Runtime-neutral: no filesystem, no environment.
|
|
3
|
+
*
|
|
4
|
+
* ★ Workers and scripts that already hold the JSON (a binding, a fetched file) call
|
|
5
|
+
* `decodeSite`. Scripts that read `HF_SITE_FILE` call `loadSite` from
|
|
6
|
+
* `@homeflare/site/load`, which ends up here too.
|
|
7
|
+
*/
|
|
8
|
+
import * as Cause from 'effect/Cause';
|
|
9
|
+
import * as Exit from 'effect/Exit';
|
|
10
|
+
import * as Schema from 'effect/Schema';
|
|
11
|
+
import * as SchemaIssue from 'effect/SchemaIssue';
|
|
12
|
+
import { SiteError } from './errors.ts';
|
|
13
|
+
import { checkDeriveVersion } from './guards.ts';
|
|
14
|
+
import { referenceIssues } from './references.ts';
|
|
15
|
+
import { type Site, SiteSchema } from './schema.ts';
|
|
16
|
+
import { VERSION } from './version.ts';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* ⛔ `onExcessProperty: 'error'` IS LOAD-BEARING, twice over. Measured 2026-09-21 on
|
|
20
|
+
* effect 4.0.0-rc.115: with the default (`ignore`), a misspelt field is dropped, AND a
|
|
21
|
+
* record entry whose key fails the key schema (`"Mgmt"` in `networks`) is dropped too —
|
|
22
|
+
* the file decodes, one network short, with no error at all.
|
|
23
|
+
* ⚠️ With it on, that bad record key reports as "excess property", which reads oddly.
|
|
24
|
+
* `formatIssues` rewrites it to say what it actually means.
|
|
25
|
+
*/
|
|
26
|
+
const strict = Schema.decodeUnknownExit(SiteSchema, { onExcessProperty: 'error', errors: 'all' });
|
|
27
|
+
const standard = SchemaIssue.makeFormatterStandardSchemaV1();
|
|
28
|
+
|
|
29
|
+
const EXCESS = 'Expected no excess property';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `pinned.certificates["vault.origin"].0` — a key that itself holds a dot is bracketed,
|
|
33
|
+
* or the path would read as two levels that do not exist.
|
|
34
|
+
*/
|
|
35
|
+
export function joinPath(segments: readonly string[]): string {
|
|
36
|
+
return segments
|
|
37
|
+
.map((segment, i) => {
|
|
38
|
+
if (segment.includes('.')) return `[${JSON.stringify(segment)}]`;
|
|
39
|
+
return i === 0 ? segment : `.${segment}`;
|
|
40
|
+
})
|
|
41
|
+
.join('');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Render a schema issue as `path: problem` lines, dropping a leading `skip` segment. */
|
|
45
|
+
export function formatIssues(issue: SchemaIssue.Issue, skip?: string): readonly string[] {
|
|
46
|
+
return standard(issue).issues.map((entry) => {
|
|
47
|
+
const segments = (entry.path ?? []).map((part) =>
|
|
48
|
+
typeof part === 'object' && part !== null ? String(part.key) : String(part),
|
|
49
|
+
);
|
|
50
|
+
if (skip !== undefined && segments[0] === skip) segments.shift();
|
|
51
|
+
const path = segments.length === 0 ? '(root)' : joinPath(segments);
|
|
52
|
+
const message =
|
|
53
|
+
entry.message === EXCESS
|
|
54
|
+
? 'unknown key (a misspelt field, or a record key that is not a lowercase label)'
|
|
55
|
+
: entry.message;
|
|
56
|
+
return `${path}: ${message}`;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Schema-only decode. Throws `decode` listing every problem, each with its path. */
|
|
61
|
+
export function decodeStrict(input: unknown): Site {
|
|
62
|
+
const exit = strict(input);
|
|
63
|
+
if (Exit.isSuccess(exit)) return exit.value;
|
|
64
|
+
const error = Cause.squash(exit.cause);
|
|
65
|
+
if (Schema.isSchemaError(error)) {
|
|
66
|
+
throw new SiteError(
|
|
67
|
+
'decode',
|
|
68
|
+
'the site file does not match the schema',
|
|
69
|
+
formatIssues(error.issue),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ValidateOptions {
|
|
76
|
+
/**
|
|
77
|
+
* The `@homeflare/site` version to hold `deriveVersion` against. Defaults to the
|
|
78
|
+
* installed one — which is the point; override only to test the refusal.
|
|
79
|
+
*/
|
|
80
|
+
readonly installed?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Cross-field references, then the derive-version guard. Returns the same site. */
|
|
84
|
+
export function validateSite(site: Site, options: ValidateOptions = {}): Site {
|
|
85
|
+
const issues = referenceIssues(site);
|
|
86
|
+
if (issues.length > 0) {
|
|
87
|
+
throw new SiteError('reference', 'the site file names things it does not declare', issues);
|
|
88
|
+
}
|
|
89
|
+
checkDeriveVersion(site, options.installed ?? VERSION);
|
|
90
|
+
return site;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Decode and validate a site from plain data (already-parsed JSON).
|
|
95
|
+
* Throws {@link SiteError}: `decode`, `reference` or `derive-version`.
|
|
96
|
+
*/
|
|
97
|
+
export function decodeSite(input: unknown, options: ValidateOptions = {}): Site {
|
|
98
|
+
return validateSite(decodeStrict(input), options);
|
|
99
|
+
}
|
package/src/derive.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `derive(site)`: every hostname, address, zone suffix, Access URL, OIDC redirect and
|
|
3
|
+
* vault mount name, built from base values. Pure — same site in, same names out.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ DERIVE ONLY WHAT FOLLOWS A RULE. Physical names, certificate hostnames, adopted ids
|
|
6
|
+
* and policy / principal lists are PINNED (see pins.ts). Anything added here must be
|
|
7
|
+
* a pure function of base values, or the relativity test stops meaning anything.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ UNKNOWN KEYS THROW. `productHost('typo')` refuses, naming what is declared. It never
|
|
10
|
+
* falls back to `typo.<apex>`: a plausible default is how a typo becomes a DNS record.
|
|
11
|
+
*
|
|
12
|
+
* ★ The result is plain data plus lookup methods. `JSON.stringify(derive(site))` is the
|
|
13
|
+
* whole rendering (methods drop out), which is what the leak and relativity tests read.
|
|
14
|
+
*/
|
|
15
|
+
import { SiteError, lookup, unknownKey } from './errors.ts';
|
|
16
|
+
import { addressOn } from './net.ts';
|
|
17
|
+
import { APEX_ZONE } from './references.ts';
|
|
18
|
+
import type { Site } from './schema.ts';
|
|
19
|
+
|
|
20
|
+
export interface DerivedHost {
|
|
21
|
+
/** `<key>.<zone>`; absent when the host declares no zone. */
|
|
22
|
+
readonly fqdn?: string;
|
|
23
|
+
/** Network key → address on that network. */
|
|
24
|
+
readonly addresses: Readonly<Record<string, string>>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DerivedVault {
|
|
28
|
+
/** `<vault-host>`: the browser / UI hostname. */
|
|
29
|
+
readonly host: string;
|
|
30
|
+
/** `<vault-api-host>`: the public API hostname. */
|
|
31
|
+
readonly apiHost: string;
|
|
32
|
+
/** `https://<vault-api-host>` — the public API. Humans; machines use Mesh or the LAN. */
|
|
33
|
+
readonly publicAddr: string;
|
|
34
|
+
/** `https://<mesh address>:<port>` — machines over the private Mesh path. */
|
|
35
|
+
readonly meshAddr: string;
|
|
36
|
+
/** The LAN pass-through proxy, as a `BAO_ADDR`. */
|
|
37
|
+
readonly lanAddr: string;
|
|
38
|
+
readonly namespace: string;
|
|
39
|
+
/** Browser callback on `<vault-host>`, then the CLI's localhost listener. */
|
|
40
|
+
readonly oidcRedirects: readonly string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface DerivedAccess {
|
|
44
|
+
readonly team: string;
|
|
45
|
+
/**
|
|
46
|
+
* `https://<team>.cloudflareaccess.com`. ★ Named for `verifyAccessJwt`'s `teamDomain`
|
|
47
|
+
* option in `@homeflare/cloudflare`, so it passes straight through.
|
|
48
|
+
*/
|
|
49
|
+
readonly teamDomain: string;
|
|
50
|
+
/** The JWKS endpoint Access signs with. */
|
|
51
|
+
readonly certsUrl: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface Derived {
|
|
55
|
+
readonly apex: string;
|
|
56
|
+
/** Zone key → FQDN (`mgmt` → `mgmt.<apex>`). */
|
|
57
|
+
readonly zones: Readonly<Record<string, string>>;
|
|
58
|
+
/** `<mgmt-zone>`. */
|
|
59
|
+
readonly mgmtZone: string;
|
|
60
|
+
readonly vault: DerivedVault;
|
|
61
|
+
readonly access: DerivedAccess;
|
|
62
|
+
readonly hosts: Readonly<Record<string, DerivedHost>>;
|
|
63
|
+
/** Product key → public hostname. */
|
|
64
|
+
readonly products: Readonly<Record<string, string>>;
|
|
65
|
+
/** Service key → URL. */
|
|
66
|
+
readonly services: Readonly<Record<string, string>>;
|
|
67
|
+
/** Cluster key → member FQDNs, in declared order. */
|
|
68
|
+
readonly clusters: Readonly<Record<string, readonly string[]>>;
|
|
69
|
+
/** Every `cloudflare-<alias>-<surface>` mount, in declared order. */
|
|
70
|
+
readonly cloudflareMounts: readonly string[];
|
|
71
|
+
|
|
72
|
+
/** A zone's FQDN. `"apex"` is the apex itself. */
|
|
73
|
+
zone(key: string): string;
|
|
74
|
+
/** A host's FQDN. Throws when the host is unknown or has no zone. */
|
|
75
|
+
host(key: string): string;
|
|
76
|
+
/** A host's address on a network. Throws when either is unknown or not a leg. */
|
|
77
|
+
address(host: string, network: string): string;
|
|
78
|
+
productHost(key: string): string;
|
|
79
|
+
/** `https://<productHost>`. */
|
|
80
|
+
productUrl(key: string): string;
|
|
81
|
+
serviceUrl(key: string): string;
|
|
82
|
+
clusterMembers(key: string): readonly string[];
|
|
83
|
+
/** Throws when the alias is unknown or that account does not list the surface. */
|
|
84
|
+
cloudflareMount(alias: string, surface: string): string;
|
|
85
|
+
accountId(alias: string): string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function zoneOf(site: Site, key: string): string {
|
|
89
|
+
if (key === APEX_ZONE) return site.apex;
|
|
90
|
+
return `${lookup('zone', site.zones, key)}.${site.apex}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function deriveHosts(site: Site): Record<string, DerivedHost> {
|
|
94
|
+
const out: Record<string, DerivedHost> = {};
|
|
95
|
+
for (const [key, host] of Object.entries(site.hosts)) {
|
|
96
|
+
const addresses: Record<string, string> = {};
|
|
97
|
+
for (const [network, number] of Object.entries(host.legs ?? {})) {
|
|
98
|
+
addresses[network] = addressOn(lookup('network', site.networks, network), number);
|
|
99
|
+
}
|
|
100
|
+
out[key] = {
|
|
101
|
+
...(host.zone === undefined ? {} : { fqdn: `${key}.${zoneOf(site, host.zone)}` }),
|
|
102
|
+
addresses,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function mapRecord<V, R>(record: Readonly<Record<string, V>>, f: (value: V, key: string) => R) {
|
|
109
|
+
const out: Record<string, R> = {};
|
|
110
|
+
for (const [key, value] of Object.entries(record)) out[key] = f(value, key);
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function derive(site: Site): Derived {
|
|
115
|
+
const hosts = deriveHosts(site);
|
|
116
|
+
const hostFqdn = (key: string): string => {
|
|
117
|
+
const fqdn = lookup('host', hosts, key).fqdn;
|
|
118
|
+
if (fqdn === undefined) {
|
|
119
|
+
throw new SiteError('unknown-key', `host "${key}" has no zone, so it has no hostname`);
|
|
120
|
+
}
|
|
121
|
+
return fqdn;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const vaultHost = `${site.vault.label}.${site.apex}`;
|
|
125
|
+
const apiHost = `${site.vault.apiLabel}.${vaultHost}`;
|
|
126
|
+
const { lan } = site.vault;
|
|
127
|
+
const teamDomain = `https://${site.cloudflare.access.team}.cloudflareaccess.com`;
|
|
128
|
+
|
|
129
|
+
const products = mapRecord(
|
|
130
|
+
site.products,
|
|
131
|
+
(p, key) => p.domain ?? `${p.label ?? key}.${site.apex}`,
|
|
132
|
+
);
|
|
133
|
+
const services = mapRecord(site.services, (s) => `${s.scheme}://${hostFqdn(s.host)}:${s.port}`);
|
|
134
|
+
const clusters = mapRecord(site.clusters, (c) => c.members.map(hostFqdn));
|
|
135
|
+
const accounts = site.cloudflare.accounts;
|
|
136
|
+
const cloudflareMounts = Object.entries(accounts).flatMap(([alias, account]) =>
|
|
137
|
+
account.surfaces.map((surface) => `cloudflare-${alias}-${surface}`),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
apex: site.apex,
|
|
142
|
+
zones: mapRecord(site.zones, (_label, key) => zoneOf(site, key)),
|
|
143
|
+
mgmtZone: zoneOf(site, 'mgmt'),
|
|
144
|
+
vault: {
|
|
145
|
+
host: vaultHost,
|
|
146
|
+
apiHost,
|
|
147
|
+
publicAddr: `https://${apiHost}`,
|
|
148
|
+
meshAddr: `https://${site.vault.meshAddress}:${site.vault.port}`,
|
|
149
|
+
lanAddr: `${lan.scheme}://${hostFqdn(lan.host)}:${lan.port}`,
|
|
150
|
+
namespace: site.vault.namespace,
|
|
151
|
+
oidcRedirects: [
|
|
152
|
+
`https://${vaultHost}/ui/vault/auth/${site.vault.oidcMount}/oidc/callback`,
|
|
153
|
+
`http://localhost:${site.vault.cliCallbackPort}/oidc/callback`,
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
access: {
|
|
157
|
+
team: site.cloudflare.access.team,
|
|
158
|
+
teamDomain,
|
|
159
|
+
certsUrl: `${teamDomain}/cdn-cgi/access/certs`,
|
|
160
|
+
},
|
|
161
|
+
hosts,
|
|
162
|
+
products,
|
|
163
|
+
services,
|
|
164
|
+
clusters,
|
|
165
|
+
cloudflareMounts,
|
|
166
|
+
|
|
167
|
+
zone: (key) => zoneOf(site, key),
|
|
168
|
+
host: hostFqdn,
|
|
169
|
+
address: (host, network) => {
|
|
170
|
+
const addresses = lookup('host', hosts, host).addresses;
|
|
171
|
+
if (!Object.hasOwn(addresses, network))
|
|
172
|
+
unknownKey(`network leg of host "${host}"`, network, Object.keys(addresses));
|
|
173
|
+
return addresses[network] as string;
|
|
174
|
+
},
|
|
175
|
+
productHost: (key) => lookup('product', products, key),
|
|
176
|
+
productUrl: (key) => `https://${lookup('product', products, key)}`,
|
|
177
|
+
serviceUrl: (key) => lookup('service', services, key),
|
|
178
|
+
clusterMembers: (key) => lookup('cluster', clusters, key),
|
|
179
|
+
cloudflareMount: (alias, surface) => {
|
|
180
|
+
const account = lookup('cloudflare account', accounts, alias);
|
|
181
|
+
if (!account.surfaces.includes(surface)) {
|
|
182
|
+
unknownKey(`surface of account "${alias}"`, surface, account.surfaces);
|
|
183
|
+
}
|
|
184
|
+
return `cloudflare-${alias}-${surface}`;
|
|
185
|
+
},
|
|
186
|
+
accountId: (alias) => lookup('cloudflare account', accounts, alias).id,
|
|
187
|
+
};
|
|
188
|
+
}
|