@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
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The value shapes a site file is made of. Each one refuses at decode time, naming its path,
|
|
3
|
+
* so a typo fails when the file is read rather than when a plan renames something.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ LOWERCASE ONLY for DNS names. Cloudflare, OpenBao and ssh compare names as strings in
|
|
6
|
+
* places, and `Mgmt.Example.com` is not `mgmt.example.com` to a principal list.
|
|
7
|
+
*/
|
|
8
|
+
import * as Schema from 'effect/Schema';
|
|
9
|
+
|
|
10
|
+
/** One DNS label: `v`, `mgmt`, `n1`. Also the shape of every key a consumer looks up. */
|
|
11
|
+
export const Label = Schema.String.check(
|
|
12
|
+
Schema.isPattern(/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/, {
|
|
13
|
+
message: 'Expected a lowercase DNS label (a-z, 0-9, inner hyphens, at most 63)',
|
|
14
|
+
}),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
/** A fully qualified name with at least two labels: `example.com`, `shop.example.net`. */
|
|
18
|
+
export const Hostname = Schema.String.check(
|
|
19
|
+
Schema.isPattern(
|
|
20
|
+
/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]([a-z0-9-]{0,61}[a-z0-9])?$/,
|
|
21
|
+
{ message: 'Expected a lowercase fully qualified hostname such as example.com' },
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
/** A certificate name: a hostname, or one leading wildcard label. */
|
|
26
|
+
export const CertificateName = Schema.String.check(
|
|
27
|
+
Schema.isPattern(
|
|
28
|
+
/^(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]([a-z0-9-]{0,61}[a-z0-9])?$/,
|
|
29
|
+
{ message: 'Expected a hostname, optionally with one leading *. wildcard' },
|
|
30
|
+
),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const OCTET = '(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])';
|
|
34
|
+
|
|
35
|
+
/** A dotted-quad IPv4 address, every octet 0–255. */
|
|
36
|
+
export const Ipv4 = Schema.String.check(
|
|
37
|
+
Schema.isPattern(new RegExp(`^(${OCTET}\\.){3}${OCTET}$`), {
|
|
38
|
+
message: 'Expected an IPv4 address such as 192.0.2.10',
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/** Parse `a.b.c.d/p` into a 32-bit base and a prefix length. `undefined` when malformed. */
|
|
43
|
+
export function parseCidr(cidr: string): { base: number; prefix: number } | undefined {
|
|
44
|
+
const match = new RegExp(`^((${OCTET}\\.){3}${OCTET})/([0-9]{1,2})$`).exec(cidr);
|
|
45
|
+
if (match === null) return undefined;
|
|
46
|
+
const prefix = Number(match[match.length - 1]);
|
|
47
|
+
const base = (match[1] ?? '').split('.').reduce((acc, octet) => acc * 256 + Number(octet), 0);
|
|
48
|
+
return { base, prefix };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* An IPv4 network, `/8` to `/30`, written at its network address.
|
|
53
|
+
*
|
|
54
|
+
* ⚠️ `192.0.2.5/24` is refused, not normalised. A host address with a prefix usually means
|
|
55
|
+
* someone pasted an interface address where a network belongs; normalising it would hide
|
|
56
|
+
* the mistake and derive every leg address from the wrong idea of the network.
|
|
57
|
+
*/
|
|
58
|
+
export const Ipv4Cidr = Schema.String.check(
|
|
59
|
+
Schema.makeFilter(
|
|
60
|
+
(value: string) => {
|
|
61
|
+
const parsed = parseCidr(value);
|
|
62
|
+
if (parsed === undefined) return 'Expected an IPv4 network such as 192.0.2.0/24';
|
|
63
|
+
if (parsed.prefix < 8 || parsed.prefix > 30) return 'Expected a prefix between /8 and /30';
|
|
64
|
+
const hostBits = 2 ** (32 - parsed.prefix);
|
|
65
|
+
if (parsed.base % hostBits !== 0) return 'Expected the network address (host bits zero)';
|
|
66
|
+
return undefined;
|
|
67
|
+
},
|
|
68
|
+
{ expected: 'an IPv4 network' },
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
/** A host's number on a network: `.11` on a /24 is 11. Checked against the prefix later. */
|
|
73
|
+
export const HostNumber = Schema.Number.check(
|
|
74
|
+
Schema.isInt(),
|
|
75
|
+
Schema.isBetween({ minimum: 1, maximum: 16_777_214 }),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
export const Port = Schema.Number.check(
|
|
79
|
+
Schema.isInt(),
|
|
80
|
+
Schema.isBetween({ minimum: 1, maximum: 65_535 }),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
/** A Cloudflare account id: 32 lowercase hex characters. */
|
|
84
|
+
export const AccountId = Schema.String.check(
|
|
85
|
+
Schema.isPattern(/^[0-9a-f]{32}$/, {
|
|
86
|
+
message: 'Expected a 32-character lowercase hex account id',
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
/** A GitHub user or organisation login. GitHub itself allows uppercase here. */
|
|
91
|
+
export const GithubOwner = Schema.String.check(
|
|
92
|
+
Schema.isPattern(/^[A-Za-z0-9]([A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/, {
|
|
93
|
+
message: 'Expected a GitHub login (letters, digits, inner hyphens)',
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
/** An absolute POSIX path with no trailing slash: `/opt/example`. */
|
|
98
|
+
export const AbsolutePath = Schema.String.check(
|
|
99
|
+
Schema.isPattern(/^(\/[^/\0]+)+$/, {
|
|
100
|
+
message: 'Expected an absolute path without a trailing slash',
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
/** A semver version, as npm writes one: `0.1.0`, `1.0.0-rc.1`. */
|
|
105
|
+
export const SemVer = Schema.String.check(
|
|
106
|
+
Schema.isPattern(/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/, { message: 'Expected a semver version' }),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
/** A key into a `pinned` table: `alerts.d1`, `ssh-host.host`. Dotted, lowercase. */
|
|
110
|
+
export const PinKey = Schema.String.check(
|
|
111
|
+
Schema.isPattern(/^[a-z0-9]+([._-][a-z0-9]+)*$/, {
|
|
112
|
+
message: 'Expected a lowercase dotted key such as alerts.d1',
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A physical resource name: Worker, D1, KV, R2, Durable Object class.
|
|
118
|
+
* ★ Case is allowed because Durable Object bindings name PascalCase classes.
|
|
119
|
+
*/
|
|
120
|
+
export const PhysicalName = Schema.String.check(
|
|
121
|
+
Schema.isPattern(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, {
|
|
122
|
+
message: 'Expected a resource name (letters, digits, dot, dash, underscore)',
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* One SSH principal or policy entry.
|
|
128
|
+
* ⛔ No commas and no whitespace. OpenBao stores principal lists comma-joined, so an entry
|
|
129
|
+
* holding a comma silently becomes two principals, and a stray space becomes a name no
|
|
130
|
+
* host will ever present.
|
|
131
|
+
*/
|
|
132
|
+
export const ListEntry = Schema.String.check(
|
|
133
|
+
Schema.isPattern(/^[^\s,]+$/, { message: 'Expected one entry with no commas or whitespace' }),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
/** A free-form identifier that must not be blank: cluster names, adopted ids, CNs. */
|
|
137
|
+
export const NonBlank = Schema.String.check(
|
|
138
|
+
Schema.isPattern(/^\S(.*\S)?$/, { message: 'Expected a non-blank value without edge spaces' }),
|
|
139
|
+
);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks that span fields: every zone, host and network a value names is declared.
|
|
3
|
+
*
|
|
4
|
+
* ★ WHY NOT IN THE SCHEMA. A struct-level filter reports one issue at the struct's own
|
|
5
|
+
* path; these checks want to name the exact field (`services.grafana.host`), and they
|
|
6
|
+
* want to report every broken reference at once rather than the first.
|
|
7
|
+
*/
|
|
8
|
+
import { lastHostNumber } from './net.ts';
|
|
9
|
+
import type { Site } from './schema.ts';
|
|
10
|
+
|
|
11
|
+
/** The zone key that means the apex itself. Reserved: a site may not declare it. */
|
|
12
|
+
export const APEX_ZONE = 'apex';
|
|
13
|
+
|
|
14
|
+
function hostHasZone(site: Site, host: string): boolean {
|
|
15
|
+
return Object.hasOwn(site.hosts, host) && site.hosts[host]?.zone !== undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function checkHosts(site: Site, issues: string[]): void {
|
|
19
|
+
for (const [key, host] of Object.entries(site.hosts)) {
|
|
20
|
+
const zone = host.zone;
|
|
21
|
+
if (zone !== undefined && zone !== APEX_ZONE && !Object.hasOwn(site.zones, zone)) {
|
|
22
|
+
issues.push(`hosts.${key}.zone: "${zone}" is not a declared zone or "${APEX_ZONE}"`);
|
|
23
|
+
}
|
|
24
|
+
for (const [network, number] of Object.entries(host.legs ?? {})) {
|
|
25
|
+
const cidr = site.networks[network];
|
|
26
|
+
if (cidr === undefined) {
|
|
27
|
+
issues.push(`hosts.${key}.legs.${network}: "${network}" is not a declared network`);
|
|
28
|
+
} else if (number > lastHostNumber(cidr)) {
|
|
29
|
+
issues.push(`hosts.${key}.legs.${network}: host number ${number} does not fit ${network}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function checkZonedHost(site: Site, path: string, host: string, issues: string[]): void {
|
|
36
|
+
if (!Object.hasOwn(site.hosts, host)) {
|
|
37
|
+
issues.push(`${path}: "${host}" is not a declared host`);
|
|
38
|
+
} else if (!hostHasZone(site, host)) {
|
|
39
|
+
issues.push(`${path}: host "${host}" has no zone, so it has no hostname`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function checkAccounts(site: Site, issues: string[]): void {
|
|
44
|
+
for (const [alias, account] of Object.entries(site.cloudflare.accounts)) {
|
|
45
|
+
const seen = new Set<string>();
|
|
46
|
+
for (const surface of account.surfaces) {
|
|
47
|
+
// ⚠️ A repeated surface derives the same mount name twice; the second declaration
|
|
48
|
+
// would silently be the same mount, so a typo'd duplicate is refused here.
|
|
49
|
+
if (seen.has(surface)) {
|
|
50
|
+
issues.push(`cloudflare.accounts.${alias}.surfaces: "${surface}" is listed twice`);
|
|
51
|
+
}
|
|
52
|
+
seen.add(surface);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Every broken reference in `site`, each naming its path. Empty when the site is sound. */
|
|
58
|
+
export function referenceIssues(site: Site): readonly string[] {
|
|
59
|
+
const issues: string[] = [];
|
|
60
|
+
|
|
61
|
+
if (Object.hasOwn(site.zones, APEX_ZONE)) {
|
|
62
|
+
issues.push(`zones.${APEX_ZONE}: reserved — a host with zone "${APEX_ZONE}" sits on the apex`);
|
|
63
|
+
}
|
|
64
|
+
checkHosts(site, issues);
|
|
65
|
+
for (const [key, cluster] of Object.entries(site.clusters)) {
|
|
66
|
+
for (const [i, member] of cluster.members.entries()) {
|
|
67
|
+
checkZonedHost(site, `clusters.${key}.members.${i}`, member, issues);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const [key, product] of Object.entries(site.products)) {
|
|
71
|
+
if (product.label !== undefined && product.domain !== undefined) {
|
|
72
|
+
issues.push(`products.${key}: set label OR domain, not both`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const [key, service] of Object.entries(site.services)) {
|
|
76
|
+
checkZonedHost(site, `services.${key}.host`, service.host, issues);
|
|
77
|
+
}
|
|
78
|
+
checkZonedHost(site, 'vault.lan.host', site.vault.lan.host, issues);
|
|
79
|
+
checkAccounts(site, issues);
|
|
80
|
+
|
|
81
|
+
return issues;
|
|
82
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The site file: BASE VALUES and PINNED values, nothing derived.
|
|
3
|
+
*
|
|
4
|
+
* ★ BASE VALUES are the few things every name is relative to — the apex, a handful of
|
|
5
|
+
* labels, networks and hosts. `derive()` builds hostnames and addresses from them, so
|
|
6
|
+
* swapping the apex changes every derived name by substitution and nothing else.
|
|
7
|
+
*
|
|
8
|
+
* ⛔ PINNED VALUES ARE NEVER DERIVED. Physical names (Workers, D1, KV, R2, Durable Objects)
|
|
9
|
+
* follow no rule, and deriving one would REPLACE it: a derived D1 name deletes the data
|
|
10
|
+
* behind the old one. Certificate hostnames on adopted certificates reissue and revoke
|
|
11
|
+
* the certificate a host is still serving if they change. Adopted ids name objects that
|
|
12
|
+
* already exist. Policy and SSH principal lists are explicit so a break-glass principal
|
|
13
|
+
* can never vanish because a network leg was renamed.
|
|
14
|
+
*
|
|
15
|
+
* ⛔ UNKNOWN KEYS ARE REFUSED (see decode.ts). A misspelt optional field would otherwise
|
|
16
|
+
* decode as "absent" and the file would look valid while saying less than its author
|
|
17
|
+
* meant.
|
|
18
|
+
*/
|
|
19
|
+
import * as Effect from 'effect/Effect';
|
|
20
|
+
import * as Schema from 'effect/Schema';
|
|
21
|
+
import {
|
|
22
|
+
AbsolutePath,
|
|
23
|
+
AccountId,
|
|
24
|
+
CertificateName,
|
|
25
|
+
GithubOwner,
|
|
26
|
+
HostNumber,
|
|
27
|
+
Hostname,
|
|
28
|
+
Ipv4,
|
|
29
|
+
Ipv4Cidr,
|
|
30
|
+
Label,
|
|
31
|
+
ListEntry,
|
|
32
|
+
NonBlank,
|
|
33
|
+
PhysicalName,
|
|
34
|
+
PinKey,
|
|
35
|
+
Port,
|
|
36
|
+
SemVer,
|
|
37
|
+
} from './primitives.ts';
|
|
38
|
+
|
|
39
|
+
/** The site file format this package reads. A new format is a new literal, never a reuse. */
|
|
40
|
+
export const SITE_FORMAT = 1;
|
|
41
|
+
|
|
42
|
+
const emptyRecord = <S extends Schema.Top>(schema: S) =>
|
|
43
|
+
schema.pipe(Schema.withDecodingDefaultKey(Effect.succeed({})));
|
|
44
|
+
|
|
45
|
+
const Scheme = Schema.Literals(['http', 'https']);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A machine. Its FQDN is `<key>.<zone>`; its address on a network is that network's base
|
|
49
|
+
* plus `legs[network]`.
|
|
50
|
+
* ★ `zone: "apex"` places the host directly under the apex (`docs.<apex>`).
|
|
51
|
+
*/
|
|
52
|
+
const Host = Schema.Struct({
|
|
53
|
+
zone: Schema.optionalKey(Label),
|
|
54
|
+
aliases: Schema.optionalKey(Schema.Array(Label)),
|
|
55
|
+
legs: Schema.optionalKey(Schema.Record(Label, HostNumber)),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** A public product: `<label ?? key>.<apex>`, or its own `domain`. Never both. */
|
|
59
|
+
const Product = Schema.Struct({
|
|
60
|
+
label: Schema.optionalKey(Label),
|
|
61
|
+
domain: Schema.optionalKey(Hostname),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** An internal endpoint on a declared host. ⛔ The scheme is explicit, never guessed. */
|
|
65
|
+
const Service = Schema.Struct({ host: Label, port: Port, scheme: Scheme });
|
|
66
|
+
|
|
67
|
+
const Account = Schema.Struct({
|
|
68
|
+
id: AccountId,
|
|
69
|
+
zones: Schema.Array(Hostname),
|
|
70
|
+
/** Each surface becomes an OpenBao mount `cloudflare-<alias>-<surface>`. */
|
|
71
|
+
surfaces: Schema.Array(Label),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const Vault = Schema.Struct({
|
|
75
|
+
/** `<vault-host>` = `<label>.<apex>`. */
|
|
76
|
+
label: Label,
|
|
77
|
+
/**
|
|
78
|
+
* `<vault-api-host>` = `<apiLabel>.<vault-host>`.
|
|
79
|
+
* ⚠️ NOT `vaultApi` beside a `vault` label. Measured 2026-09-21 on effect 4.0.0-rc.115:
|
|
80
|
+
* constant-cased env names nest by prefix, so `HF_SITE_VAULT_API` made the provider
|
|
81
|
+
* read `vault` as a record and the whole decode failed "Expected string". No field
|
|
82
|
+
* name here may be another's name plus `_…`; tests/overrides.test.ts asserts it.
|
|
83
|
+
*/
|
|
84
|
+
apiLabel: Label,
|
|
85
|
+
/**
|
|
86
|
+
* ⛔ PINNED IDENTITY: the `cluster_name` the vault reports on `sys/health`. Set at init,
|
|
87
|
+
* never derived; plans compare it before touching anything (see identity.ts).
|
|
88
|
+
*/
|
|
89
|
+
clusterName: NonBlank,
|
|
90
|
+
namespace: Schema.String.pipe(Schema.withDecodingDefaultKey(Effect.succeed(''))),
|
|
91
|
+
port: Port.pipe(Schema.withDecodingDefaultKey(Effect.succeed(8200))),
|
|
92
|
+
/** The vault's address on the private Mesh path. Machines use it; strict TLS applies. */
|
|
93
|
+
meshAddress: Ipv4,
|
|
94
|
+
/** The LAN pass-through proxy: a declared host, its port, and its scheme. */
|
|
95
|
+
lan: Schema.Struct({ host: Label, port: Port, scheme: Scheme }),
|
|
96
|
+
oidcMount: Label.pipe(Schema.withDecodingDefaultKey(Effect.succeed('oidc'))),
|
|
97
|
+
/** The port `bao login -method=oidc` listens on for its localhost callback. */
|
|
98
|
+
cliCallbackPort: Port.pipe(Schema.withDecodingDefaultKey(Effect.succeed(8250))),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const Pinned = Schema.Struct({
|
|
102
|
+
names: emptyRecord(Schema.Record(PinKey, PhysicalName)),
|
|
103
|
+
certificates: emptyRecord(Schema.Record(PinKey, Schema.NonEmptyArray(CertificateName))),
|
|
104
|
+
adopted: emptyRecord(Schema.Record(PinKey, NonBlank)),
|
|
105
|
+
policies: emptyRecord(Schema.Record(PinKey, Schema.Array(ListEntry))),
|
|
106
|
+
sshPrincipals: emptyRecord(Schema.Record(PinKey, Schema.Array(ListEntry))),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
export const SiteSchema = Schema.Struct({
|
|
110
|
+
/** The file format. Only {@link SITE_FORMAT} decodes. */
|
|
111
|
+
version: Schema.Literal(SITE_FORMAT),
|
|
112
|
+
/**
|
|
113
|
+
* The `@homeflare/site` version whose derive rules this file was reviewed against.
|
|
114
|
+
* ⛔ A repo refuses to load when its installed package differs (see guards.ts), so a
|
|
115
|
+
* derive change can never rename live objects without someone bumping this line.
|
|
116
|
+
*/
|
|
117
|
+
deriveVersion: SemVer,
|
|
118
|
+
/** ⛔ Only a `live` site may plan stage `live` (see guards.ts). */
|
|
119
|
+
kind: Schema.Literals(['live', 'testing', 'example']),
|
|
120
|
+
apex: Hostname,
|
|
121
|
+
/**
|
|
122
|
+
* Zone suffixes under the apex: `{ "mgmt": "mgmt" }` gives `mgmt.<apex>`.
|
|
123
|
+
* ⛔ `mgmt` is required (it is `<mgmt-zone>`), and the key `apex` is reserved.
|
|
124
|
+
*/
|
|
125
|
+
zones: Schema.StructWithRest(Schema.Struct({ mgmt: Label }), [Schema.Record(Label, Label)]),
|
|
126
|
+
networks: Schema.Record(Label, Ipv4Cidr),
|
|
127
|
+
hosts: Schema.Record(Label, Host),
|
|
128
|
+
clusters: emptyRecord(Schema.Record(Label, Schema.Struct({ members: Schema.Array(Label) }))),
|
|
129
|
+
products: emptyRecord(Schema.Record(Label, Product)),
|
|
130
|
+
services: emptyRecord(Schema.Record(Label, Service)),
|
|
131
|
+
cloudflare: Schema.Struct({
|
|
132
|
+
accounts: Schema.Record(Label, Account),
|
|
133
|
+
access: Schema.Struct({ team: Label }),
|
|
134
|
+
}),
|
|
135
|
+
github: Schema.Struct({ owner: GithubOwner }),
|
|
136
|
+
paths: Schema.Struct({ estateRoot: AbsolutePath }),
|
|
137
|
+
vault: Vault,
|
|
138
|
+
pinned: Pinned.pipe(Schema.withDecodingDefaultKey(Effect.succeed({}))),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/** A decoded site: defaults applied, every value checked. */
|
|
142
|
+
export type Site = typeof SiteSchema.Type;
|
|
143
|
+
/** What a site file may contain before defaults are applied. */
|
|
144
|
+
export type SiteInput = typeof SiteSchema.Encoded;
|
package/src/tokens.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The placeholders public docs use instead of estate values.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ A PUBLIC DOC NEVER NAMES A LIVE VALUE. It writes `<vault-host>`, and anyone reading
|
|
5
|
+
* it substitutes their own. `tokenValues(site)` is that substitution for one site; a
|
|
6
|
+
* leak gate builds its needles from `tokenValues(liveSite)` in memory, so the live
|
|
7
|
+
* values never have to be written down anywhere else.
|
|
8
|
+
*
|
|
9
|
+
* ★ `<cluster>` HAS NO SINGLE VALUE. It stands for any key of `site.clusters` (as
|
|
10
|
+
* `<alias>` and `<surface>` do inside `cloudflare-<alias>-<surface>`), so it is listed
|
|
11
|
+
* for doc lint but never substituted.
|
|
12
|
+
*/
|
|
13
|
+
import { derive } from './derive.ts';
|
|
14
|
+
import type { Site } from './schema.ts';
|
|
15
|
+
|
|
16
|
+
export const SITE_TOKENS = {
|
|
17
|
+
'<apex>': 'the apex domain every derived name hangs off',
|
|
18
|
+
'<vault-host>': 'the vault UI hostname: <vault label>.<apex>',
|
|
19
|
+
'<vault-api-host>': 'the vault public API hostname: <api label>.<vault-host>',
|
|
20
|
+
'<mgmt-zone>': 'the management zone: <mgmt label>.<apex>',
|
|
21
|
+
'<access-team>': 'the Cloudflare Access team name (<access-team>.cloudflareaccess.com)',
|
|
22
|
+
'<github-owner>': 'the GitHub user or organisation that owns the repos',
|
|
23
|
+
'<estate-root>': 'the absolute directory holding host-local estate files',
|
|
24
|
+
'<cluster>': 'any key of site.clusters — a variable, never substituted',
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
export type SiteToken = keyof typeof SITE_TOKENS;
|
|
28
|
+
|
|
29
|
+
/** Tokens with exactly one value per site. */
|
|
30
|
+
export type ValuedToken = Exclude<SiteToken, '<cluster>'>;
|
|
31
|
+
|
|
32
|
+
/** Each single-valued token's value for `site`. */
|
|
33
|
+
export function tokenValues(site: Site): Readonly<Record<ValuedToken, string>> {
|
|
34
|
+
const derived = derive(site);
|
|
35
|
+
return {
|
|
36
|
+
'<apex>': site.apex,
|
|
37
|
+
'<vault-host>': derived.vault.host,
|
|
38
|
+
'<vault-api-host>': derived.vault.apiHost,
|
|
39
|
+
'<mgmt-zone>': derived.mgmtZone,
|
|
40
|
+
'<access-team>': site.cloudflare.access.team,
|
|
41
|
+
'<github-owner>': site.github.owner,
|
|
42
|
+
'<estate-root>': site.paths.estateRoot,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Replace every single-valued token in `text` with its value for `site`.
|
|
48
|
+
* ★ ONE PASS, longest token first in the alternation. A value is never read again, so a
|
|
49
|
+
* value that itself contains a token stays literal. ⚠️ Measured 2026-09-21: the earlier
|
|
50
|
+
* token-by-token loop rendered an estate root of `/opt/<apex>` as `/opt/example.com`.
|
|
51
|
+
*/
|
|
52
|
+
export function renderTokens(text: string, site: Site): string {
|
|
53
|
+
const values: Readonly<Record<string, string>> = tokenValues(site);
|
|
54
|
+
const tokens = Object.keys(values).sort((a, b) => b.length - a.length);
|
|
55
|
+
const pattern = new RegExp(
|
|
56
|
+
tokens.map((t) => t.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'),
|
|
57
|
+
'g',
|
|
58
|
+
);
|
|
59
|
+
return text.replaceAll(pattern, (token) => values[token] ?? token);
|
|
60
|
+
}
|
package/src/version.ts
ADDED