@wtfalch/foundry 0.34.0 → 0.35.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/bootstrap/bootstrap.d.ts +1 -1
- package/dist/bootstrap/bootstrap.js +49 -11
- package/dist/bootstrap/declare.d.ts +12 -5
- package/dist/bootstrap/declare.js +33 -18
- package/dist/bootstrap/versions.d.ts +20 -0
- package/dist/bootstrap/versions.js +46 -0
- package/dist/calls-sfu.js +3 -3
- package/dist/index.d.ts +9 -4
- package/dist/index.js +5 -2
- package/dist/migrations/0053_heartbeats_provider.sql +9 -0
- package/dist/migrations/0053_org_version_pin.sql +6 -0
- package/dist/org.d.ts +60 -14
- package/dist/org.js +118 -28
- package/dist/plan/backup-gate.d.ts +93 -0
- package/dist/plan/backup-gate.js +261 -0
- package/dist/plan/broker-entries.d.ts +9 -1
- package/dist/plan/broker-entries.js +20 -3
- package/dist/plan/build-record.d.ts +56 -0
- package/dist/plan/build-record.js +109 -0
- package/dist/plan/calls-matrix.js +3 -3
- package/dist/plan/cloudflared-connector.js +2 -2
- package/dist/plan/company-backups.d.ts +31 -0
- package/dist/plan/company-backups.js +101 -0
- package/dist/plan/deploy-health.d.ts +12 -2
- package/dist/plan/deploy-health.js +3 -1
- package/dist/plan/destinations.d.ts +73 -0
- package/dist/plan/destinations.js +217 -0
- package/dist/plan/diff.d.ts +17 -8
- package/dist/plan/diff.js +131 -131
- package/dist/plan/edge-access.d.ts +15 -0
- package/dist/plan/edge-access.js +21 -2
- package/dist/plan/executor.d.ts +166 -7
- package/dist/plan/executor.js +547 -106
- package/dist/plan/health-targets.d.ts +1 -3
- package/dist/plan/health-targets.js +36 -36
- package/dist/plan/held-key-refs.d.ts +5 -0
- package/dist/plan/held-key-refs.js +5 -0
- package/dist/plan/hostnames.d.ts +10 -0
- package/dist/plan/hostnames.js +14 -0
- package/dist/plan/index.d.ts +5 -0
- package/dist/plan/index.js +3 -0
- package/dist/plan/live.d.ts +6 -0
- package/dist/plan/live.js +3 -2
- package/dist/plan/locked-hostnames.d.ts +2 -1
- package/dist/plan/locked-hostnames.js +16 -3
- package/dist/plan/login-host.d.ts +9 -20
- package/dist/plan/login-host.js +10 -25
- package/dist/plan/mail-relay.d.ts +48 -29
- package/dist/plan/mail-relay.js +145 -68
- package/dist/plan/network-move.d.ts +20 -0
- package/dist/plan/network-move.js +41 -0
- package/dist/plan/oikos-ingress.d.ts +111 -0
- package/dist/plan/oikos-ingress.js +231 -0
- package/dist/plan/org-names.d.ts +8 -2
- package/dist/plan/org-names.js +57 -1
- package/dist/plan/own-diff.d.ts +9 -8
- package/dist/plan/own-diff.js +42 -34
- package/dist/plan/own-hostnames.d.ts +27 -0
- package/dist/plan/own-hostnames.js +24 -0
- package/dist/plan/own-limits.d.ts +198 -7
- package/dist/plan/own-limits.js +163 -32
- package/dist/plan/own-services.js +3 -1
- package/dist/plan/own-sfu.js +3 -2
- package/dist/plan/plan-apply.d.ts +47 -7
- package/dist/plan/plan-apply.js +184 -38
- package/dist/plan/platform-diff.d.ts +133 -17
- package/dist/plan/platform-diff.js +231 -68
- package/dist/plan/platform-org.d.ts +79 -10
- package/dist/plan/platform-org.js +93 -13
- package/dist/plan/platform-providers.d.ts +118 -73
- package/dist/plan/platform-providers.js +770 -231
- package/dist/plan/proxy-labels.d.ts +57 -11
- package/dist/plan/proxy-labels.js +94 -14
- package/dist/plan/run-options.d.ts +19 -0
- package/dist/plan/run-options.js +46 -0
- package/dist/plan/zitadel-login-policy.d.ts +1 -1
- package/dist/plan/zitadel-login-policy.js +76 -8
- package/dist/platform.d.ts +32 -0
- package/dist/platform.js +16 -0
- package/dist/provision.d.ts +2 -0
- package/dist/provision.js +3 -1
- package/dist/slug.d.ts +3 -1
- package/dist/slug.js +25 -3
- package/dist/store/postgres.js +43 -1
- package/dist/store/schema.d.ts +36 -0
- package/dist/store/schema.js +4 -0
- package/package.json +3 -3
- package/versions.digests.json +11 -0
|
@@ -8,22 +8,55 @@ export interface EntryLabelSpec {
|
|
|
8
8
|
wildcardDomain: string | null;
|
|
9
9
|
port: number;
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* The label every routed container carries, with the install's token as its value. Traefik is told
|
|
13
|
+
* (`--providers.docker.constraints=Label(`foundry.edge`,`<token>`)`) to route only containers that carry
|
|
14
|
+
* it, so a route an image declares in its own `LABEL`s, which cannot know the token, is ignored.
|
|
15
|
+
*/
|
|
16
|
+
export declare const EDGE_LABEL = "foundry.edge";
|
|
11
17
|
/**
|
|
12
18
|
* The scope key holding the hash of the label set last patched onto an
|
|
13
19
|
* entry's app. Present only for an entry that has had a custom label set.
|
|
14
20
|
*/
|
|
15
21
|
export declare function appLabelsAppliedScopeKey(entryKey: string): string;
|
|
22
|
+
/** The entry routes ({@link entryRoutesMarker}) the company tunnel's ingress was last written with. */
|
|
23
|
+
export declare const CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY = "cloudflare_tunnel_entry_routes";
|
|
24
|
+
/**
|
|
25
|
+
* The entry names in the platform's oikos.host zone ({@link entryOikosHostnames}) the company tunnel's records were
|
|
26
|
+
* last written for, comma-joined. Recorded only once every record is in place.
|
|
27
|
+
*/
|
|
28
|
+
export declare const CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY = "cloudflare_tunnel_oikos_hostnames";
|
|
29
|
+
/**
|
|
30
|
+
* The run label every customer hosting-entry container gets. A run label overrides the image's own
|
|
31
|
+
* `LABEL` of the same key, so Traefik ignores the container whatever its image says: a customer image
|
|
32
|
+
* cannot route a hostname it does not own. The company's tunnel reaches the container directly
|
|
33
|
+
* ({@link entryRoutes}); no Traefik router and no Coolify `domains` are involved.
|
|
34
|
+
*/
|
|
35
|
+
export declare const ENTRY_CONTAINER_LABELS = "traefik.enable=false";
|
|
36
|
+
/**
|
|
37
|
+
* What an entry's routing is built from: the names it is served at, the wildcard domain when it
|
|
38
|
+
* declares `'*'`, and the port its container listens on. Every entry has one, because every entry
|
|
39
|
+
* carries {@link ENTRY_CONTAINER_LABELS}. Coolify's default port for an app is 80.
|
|
40
|
+
*/
|
|
41
|
+
export declare function entryLabelSpec(org: DeclaredOrg, entry: HostingEntry): EntryLabelSpec;
|
|
42
|
+
/**
|
|
43
|
+
* The company tunnel's ingress for the hosting entries: each name, and the wildcard last, goes straight to
|
|
44
|
+
* `http://<container name>:<port>` on the company network, never through `coolify-proxy`. A wildcard
|
|
45
|
+
* entry gets a wildcard hostname rule; the first entry that declares `'*'` owns it, and the first entry
|
|
46
|
+
* that declares a name owns it. An entry with no name (internal) has no rule. This holds for a company
|
|
47
|
+
* without a domain too: its `<slug>.oikos.host` name is routed by the company's own tunnel
|
|
48
|
+
* ({@link entryOikosHostnames}), not by the platform's shared ingress.
|
|
49
|
+
*/
|
|
50
|
+
export declare function entryRoutes(org: DeclaredOrg): Array<{
|
|
51
|
+
hostname: string;
|
|
52
|
+
service: string;
|
|
53
|
+
}>;
|
|
54
|
+
/** The scope value recording the routes the tunnel step last wrote; the planner compares it to {@link entryRoutes}. */
|
|
55
|
+
export declare function entryRoutesMarker(org: DeclaredOrg): string;
|
|
16
56
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* set, so an entry with `'*'` carries its exact `Host` rules as well. An entry
|
|
20
|
-
* that already has a custom set (`hadLabels`) keeps one, without the wildcard
|
|
21
|
-
* rule, so a removed `'*'` stops matching and a later name change still
|
|
22
|
-
* reaches the app: an empty `custom_labels` is refused by Coolify's API.
|
|
23
|
-
* Coolify's default port for an app is 80.
|
|
57
|
+
* A short digest of the spec; the same spec always gives the same hash. The token is left out: the
|
|
58
|
+
* planner has none, and a changed token reaches a live app through the comparison of its live labels.
|
|
24
59
|
*/
|
|
25
|
-
export declare function entryLabelSpec(org: DeclaredOrg, entry: HostingEntry, hadLabels: boolean): EntryLabelSpec | null;
|
|
26
|
-
/** A short digest of the spec; the same spec always gives the same hash. */
|
|
27
60
|
export declare function entryLabelHash(spec: EntryLabelSpec): string;
|
|
28
61
|
/**
|
|
29
62
|
* The Traefik labels for an entry's app, one per line. Coolify reaches an app
|
|
@@ -32,11 +65,24 @@ export declare function entryLabelHash(spec: EntryLabelSpec): string;
|
|
|
32
65
|
* `Host(name) && PathPrefix(/)`, default priority. The wildcard is one
|
|
33
66
|
* `HostRegexp` router at `priority=1`, so any exact name, this app's or
|
|
34
67
|
* another's, wins over it. The regex matches one label (`[a-z0-9-]+`), the
|
|
35
|
-
* same depth Cloudflare's Universal certificate covers.
|
|
68
|
+
* same depth Cloudflare's Universal certificate covers. A set that routes
|
|
69
|
+
* anything carries {@link EDGE_LABEL}; the "no route" set does not need it.
|
|
36
70
|
*/
|
|
37
|
-
export declare function entryProxyLabels(appUuid: string, spec: EntryLabelSpec): string;
|
|
71
|
+
export declare function entryProxyLabels(appUuid: string, spec: EntryLabelSpec, token: string): string;
|
|
72
|
+
/** Whether a label set routes something but does not carry the token: Traefik would ignore it once the constraint is on. */
|
|
73
|
+
export declare function routesWithoutToken(labels: string, token: string): boolean;
|
|
38
74
|
/**
|
|
39
75
|
* Whether two label sets hold the same labels. Order and the `coolify.*`
|
|
40
76
|
* lines Coolify adds and strips itself do not count.
|
|
41
77
|
*/
|
|
42
78
|
export declare function sameLabelSets(a: string, b: string): boolean;
|
|
79
|
+
/**
|
|
80
|
+
* The entry names that live in the platform's `oikos.host` zone (a company with no domain of its own, or with
|
|
81
|
+
* `usesDomain` off): the names the company's tunnel gets a proxied CNAME for. Sorted.
|
|
82
|
+
*/
|
|
83
|
+
export declare function entryOikosHostnames(org: DeclaredOrg): string[];
|
|
84
|
+
/**
|
|
85
|
+
* Whether `name` is one the company may write in the platform's `oikos.host` zone: exactly its own
|
|
86
|
+
* `<slug>.oikos.host`. A company writes no other company's name, whatever an entry declares.
|
|
87
|
+
*/
|
|
88
|
+
export declare function ownsOikosName(org: DeclaredOrg, name: string): boolean;
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { entryHostnames, wildcardHostname } from './hostnames.js';
|
|
3
|
+
import { NO_DOMAIN_ZONE } from './own-hostnames.js';
|
|
4
|
+
import { appNameFor } from './platform-diff.js';
|
|
5
|
+
/**
|
|
6
|
+
* The label every routed container carries, with the install's token as its value. Traefik is told
|
|
7
|
+
* (`--providers.docker.constraints=Label(`foundry.edge`,`<token>`)`) to route only containers that carry
|
|
8
|
+
* it, so a route an image declares in its own `LABEL`s, which cannot know the token, is ignored.
|
|
9
|
+
*/
|
|
10
|
+
export const EDGE_LABEL = 'foundry.edge';
|
|
11
|
+
/** The token is a base64url string; anything else would not survive a label line or Traefik's constraint syntax. */
|
|
12
|
+
const TOKEN_SHAPE = /^[A-Za-z0-9_-]{32,128}$/;
|
|
13
|
+
function checkToken(token) {
|
|
14
|
+
if (!TOKEN_SHAPE.test(token))
|
|
15
|
+
throw new Error('proxy labels: the label token is malformed');
|
|
16
|
+
}
|
|
3
17
|
/**
|
|
4
18
|
* The scope key holding the hash of the label set last patched onto an
|
|
5
19
|
* entry's app. Present only for an entry that has had a custom label set.
|
|
@@ -7,26 +21,68 @@ import { entryHostnames, wildcardHostname } from './hostnames.js';
|
|
|
7
21
|
export function appLabelsAppliedScopeKey(entryKey) {
|
|
8
22
|
return `app_labels_applied.${entryKey}`;
|
|
9
23
|
}
|
|
24
|
+
/** The entry routes ({@link entryRoutesMarker}) the company tunnel's ingress was last written with. */
|
|
25
|
+
export const CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY = 'cloudflare_tunnel_entry_routes';
|
|
10
26
|
/**
|
|
11
|
-
* The
|
|
12
|
-
*
|
|
13
|
-
* set, so an entry with `'*'` carries its exact `Host` rules as well. An entry
|
|
14
|
-
* that already has a custom set (`hadLabels`) keeps one, without the wildcard
|
|
15
|
-
* rule, so a removed `'*'` stops matching and a later name change still
|
|
16
|
-
* reaches the app: an empty `custom_labels` is refused by Coolify's API.
|
|
17
|
-
* Coolify's default port for an app is 80.
|
|
27
|
+
* The entry names in the platform's oikos.host zone ({@link entryOikosHostnames}) the company tunnel's records were
|
|
28
|
+
* last written for, comma-joined. Recorded only once every record is in place.
|
|
18
29
|
*/
|
|
19
|
-
export
|
|
30
|
+
export const CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY = 'cloudflare_tunnel_oikos_hostnames';
|
|
31
|
+
/**
|
|
32
|
+
* The run label every customer hosting-entry container gets. A run label overrides the image's own
|
|
33
|
+
* `LABEL` of the same key, so Traefik ignores the container whatever its image says: a customer image
|
|
34
|
+
* cannot route a hostname it does not own. The company's tunnel reaches the container directly
|
|
35
|
+
* ({@link entryRoutes}); no Traefik router and no Coolify `domains` are involved.
|
|
36
|
+
*/
|
|
37
|
+
export const ENTRY_CONTAINER_LABELS = 'traefik.enable=false';
|
|
38
|
+
/**
|
|
39
|
+
* What an entry's routing is built from: the names it is served at, the wildcard domain when it
|
|
40
|
+
* declares `'*'`, and the port its container listens on. Every entry has one, because every entry
|
|
41
|
+
* carries {@link ENTRY_CONTAINER_LABELS}. Coolify's default port for an app is 80.
|
|
42
|
+
*/
|
|
43
|
+
export function entryLabelSpec(org, entry) {
|
|
20
44
|
const wildcard = entry.hostnames.includes('*') ? wildcardHostname(org) : null;
|
|
21
|
-
if (!wildcard && !hadLabels)
|
|
22
|
-
return null;
|
|
23
45
|
return {
|
|
24
46
|
names: entryHostnames(org, entry),
|
|
25
47
|
wildcardDomain: wildcard ? wildcard.slice(2) : null,
|
|
26
48
|
port: entry.buildPack === 'static' ? 80 : (entry.port ?? 80),
|
|
27
49
|
};
|
|
28
50
|
}
|
|
29
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* The company tunnel's ingress for the hosting entries: each name, and the wildcard last, goes straight to
|
|
53
|
+
* `http://<container name>:<port>` on the company network, never through `coolify-proxy`. A wildcard
|
|
54
|
+
* entry gets a wildcard hostname rule; the first entry that declares `'*'` owns it, and the first entry
|
|
55
|
+
* that declares a name owns it. An entry with no name (internal) has no rule. This holds for a company
|
|
56
|
+
* without a domain too: its `<slug>.oikos.host` name is routed by the company's own tunnel
|
|
57
|
+
* ({@link entryOikosHostnames}), not by the platform's shared ingress.
|
|
58
|
+
*/
|
|
59
|
+
export function entryRoutes(org) {
|
|
60
|
+
if (!org.hosting.enabled)
|
|
61
|
+
return [];
|
|
62
|
+
const routes = [];
|
|
63
|
+
let wildcard;
|
|
64
|
+
for (const entry of org.hosting.entries) {
|
|
65
|
+
const service = `http://${appNameFor(org, entry)}:${entryLabelSpec(org, entry).port}`;
|
|
66
|
+
for (const hostname of entryHostnames(org, entry)) {
|
|
67
|
+
if (!routes.some((r) => r.hostname === hostname))
|
|
68
|
+
routes.push({ hostname, service });
|
|
69
|
+
}
|
|
70
|
+
const wildcardName = entry.hostnames.includes('*') ? wildcardHostname(org) : null;
|
|
71
|
+
if (wildcardName && !wildcard)
|
|
72
|
+
wildcard = { hostname: wildcardName, service };
|
|
73
|
+
}
|
|
74
|
+
if (wildcard)
|
|
75
|
+
routes.push(wildcard);
|
|
76
|
+
return routes;
|
|
77
|
+
}
|
|
78
|
+
/** The scope value recording the routes the tunnel step last wrote; the planner compares it to {@link entryRoutes}. */
|
|
79
|
+
export function entryRoutesMarker(org) {
|
|
80
|
+
return JSON.stringify(entryRoutes(org));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* A short digest of the spec; the same spec always gives the same hash. The token is left out: the
|
|
84
|
+
* planner has none, and a changed token reaches a live app through the comparison of its live labels.
|
|
85
|
+
*/
|
|
30
86
|
export function entryLabelHash(spec) {
|
|
31
87
|
return createHash('sha256').update(JSON.stringify(spec)).digest('hex').slice(0, 16);
|
|
32
88
|
}
|
|
@@ -37,9 +93,11 @@ export function entryLabelHash(spec) {
|
|
|
37
93
|
* `Host(name) && PathPrefix(/)`, default priority. The wildcard is one
|
|
38
94
|
* `HostRegexp` router at `priority=1`, so any exact name, this app's or
|
|
39
95
|
* another's, wins over it. The regex matches one label (`[a-z0-9-]+`), the
|
|
40
|
-
* same depth Cloudflare's Universal certificate covers.
|
|
96
|
+
* same depth Cloudflare's Universal certificate covers. A set that routes
|
|
97
|
+
* anything carries {@link EDGE_LABEL}; the "no route" set does not need it.
|
|
41
98
|
*/
|
|
42
|
-
export function entryProxyLabels(appUuid, spec) {
|
|
99
|
+
export function entryProxyLabels(appUuid, spec, token) {
|
|
100
|
+
checkToken(token);
|
|
43
101
|
const labels = [];
|
|
44
102
|
const route = (id, rule, priority) => {
|
|
45
103
|
const router = `traefik.http.routers.${id}`;
|
|
@@ -56,9 +114,14 @@ export function entryProxyLabels(appUuid, spec) {
|
|
|
56
114
|
}
|
|
57
115
|
if (labels.length === 0)
|
|
58
116
|
return 'traefik.enable=false';
|
|
59
|
-
labels.push('traefik.enable=true', 'traefik.http.middlewares.gzip.compress=true');
|
|
117
|
+
labels.push('traefik.enable=true', 'traefik.http.middlewares.gzip.compress=true', `${EDGE_LABEL}=${token}`);
|
|
60
118
|
return labels.sort().join('\n');
|
|
61
119
|
}
|
|
120
|
+
/** Whether a label set routes something but does not carry the token: Traefik would ignore it once the constraint is on. */
|
|
121
|
+
export function routesWithoutToken(labels, token) {
|
|
122
|
+
const lines = labels.split(/\r\n|\n|\r/);
|
|
123
|
+
return lines.includes('traefik.enable=true') && !lines.includes(`${EDGE_LABEL}=${token}`);
|
|
124
|
+
}
|
|
62
125
|
/**
|
|
63
126
|
* Whether two label sets hold the same labels. Order and the `coolify.*`
|
|
64
127
|
* lines Coolify adds and strips itself do not count.
|
|
@@ -71,3 +134,20 @@ export function sameLabelSets(a, b) {
|
|
|
71
134
|
.join('\n');
|
|
72
135
|
return lines(a) === lines(b);
|
|
73
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* The entry names that live in the platform's `oikos.host` zone (a company with no domain of its own, or with
|
|
139
|
+
* `usesDomain` off): the names the company's tunnel gets a proxied CNAME for. Sorted.
|
|
140
|
+
*/
|
|
141
|
+
export function entryOikosHostnames(org) {
|
|
142
|
+
return entryRoutes(org)
|
|
143
|
+
.map((r) => r.hostname)
|
|
144
|
+
.filter((h) => h.endsWith(`.${NO_DOMAIN_ZONE}`))
|
|
145
|
+
.sort();
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Whether `name` is one the company may write in the platform's `oikos.host` zone: exactly its own
|
|
149
|
+
* `<slug>.oikos.host`. A company writes no other company's name, whatever an entry declares.
|
|
150
|
+
*/
|
|
151
|
+
export function ownsOikosName(org, name) {
|
|
152
|
+
return name.toLowerCase() === `${org.slug}.${NO_DOMAIN_ZONE}`;
|
|
153
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Linux capabilities of every container foundry makes through an application call, sent in
|
|
3
|
+
* Coolify's `custom_docker_run_options`. Coolify turns `--cap-drop`, `--cap-add` and `--security-opt`
|
|
4
|
+
* into compose keys; its parser reads a value up to the first dash, so `no-new-privileges:true` is
|
|
5
|
+
* not sent. A Coolify database has no such field and keeps Docker's defaults.
|
|
6
|
+
*/
|
|
7
|
+
/** What no foundry service needs: raw sockets, device nodes, file capabilities, chroot, audit writes. */
|
|
8
|
+
export declare const CAPS_TIER_A: readonly string[];
|
|
9
|
+
/** A customer's own app: nothing, then back what nginx, node and `gosu` style entrypoints need. */
|
|
10
|
+
export declare const CAPS_TIER_B: readonly string[];
|
|
11
|
+
export type CapabilityTier = 'A' | 'B';
|
|
12
|
+
/**
|
|
13
|
+
* `existing` with its capability options replaced by the tier's. Everything else in it (the
|
|
14
|
+
* `--entrypoint "..."`) stays as it was, so a step that sends its own entrypoint and the capability
|
|
15
|
+
* wrapper can both write the field. Running it twice gives the same string.
|
|
16
|
+
*/
|
|
17
|
+
export declare function withCapabilities(existing: string | null | undefined, tier: CapabilityTier): string;
|
|
18
|
+
/** Whether `existing` holds exactly the tier's capability options. */
|
|
19
|
+
export declare function hasCapabilities(existing: string | null | undefined, tier: CapabilityTier): boolean;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Linux capabilities of every container foundry makes through an application call, sent in
|
|
3
|
+
* Coolify's `custom_docker_run_options`. Coolify turns `--cap-drop`, `--cap-add` and `--security-opt`
|
|
4
|
+
* into compose keys; its parser reads a value up to the first dash, so `no-new-privileges:true` is
|
|
5
|
+
* not sent. A Coolify database has no such field and keeps Docker's defaults.
|
|
6
|
+
*/
|
|
7
|
+
/** What no foundry service needs: raw sockets, device nodes, file capabilities, chroot, audit writes. */
|
|
8
|
+
export const CAPS_TIER_A = [
|
|
9
|
+
'--cap-drop=NET_RAW',
|
|
10
|
+
'--cap-drop=MKNOD',
|
|
11
|
+
'--cap-drop=SETFCAP',
|
|
12
|
+
'--cap-drop=SYS_CHROOT',
|
|
13
|
+
'--cap-drop=AUDIT_WRITE',
|
|
14
|
+
];
|
|
15
|
+
/** A customer's own app: nothing, then back what nginx, node and `gosu` style entrypoints need. */
|
|
16
|
+
export const CAPS_TIER_B = [
|
|
17
|
+
'--cap-drop=ALL',
|
|
18
|
+
'--cap-add=CHOWN',
|
|
19
|
+
'--cap-add=DAC_OVERRIDE',
|
|
20
|
+
'--cap-add=FOWNER',
|
|
21
|
+
'--cap-add=SETGID',
|
|
22
|
+
'--cap-add=SETUID',
|
|
23
|
+
'--cap-add=NET_BIND_SERVICE',
|
|
24
|
+
];
|
|
25
|
+
const tokens = (tier) => tier === 'B' ? CAPS_TIER_B : CAPS_TIER_A;
|
|
26
|
+
/** One `--cap-drop` or `--cap-add` option, with the space or equals sign and its value. */
|
|
27
|
+
const CAP_OPTION = /\s*--cap-(?:drop|add)(?:=|\s+)\S+/g;
|
|
28
|
+
/** The cap options of a run options string, in the order they stand. */
|
|
29
|
+
function capOptionsOf(options) {
|
|
30
|
+
return (options ?? '').match(CAP_OPTION)?.map((o) => o.trim().replace(/\s+/, '=')) ?? [];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* `existing` with its capability options replaced by the tier's. Everything else in it (the
|
|
34
|
+
* `--entrypoint "..."`) stays as it was, so a step that sends its own entrypoint and the capability
|
|
35
|
+
* wrapper can both write the field. Running it twice gives the same string.
|
|
36
|
+
*/
|
|
37
|
+
export function withCapabilities(existing, tier) {
|
|
38
|
+
const rest = (existing ?? '').replace(CAP_OPTION, '').trim();
|
|
39
|
+
return [rest, ...tokens(tier)].filter(Boolean).join(' ');
|
|
40
|
+
}
|
|
41
|
+
/** Whether `existing` holds exactly the tier's capability options. */
|
|
42
|
+
export function hasCapabilities(existing, tier) {
|
|
43
|
+
const have = capOptionsOf(existing);
|
|
44
|
+
const want = tokens(tier);
|
|
45
|
+
return have.length === want.length && want.every((t) => have.includes(t));
|
|
46
|
+
}
|
|
@@ -20,4 +20,4 @@ export interface ZitadelLoginPolicyOptions {
|
|
|
20
20
|
* policy (POST), so a later change to the default cannot turn registration
|
|
21
21
|
* back on. A custom policy keeps its own factors and IdPs (PUT).
|
|
22
22
|
*/
|
|
23
|
-
export declare function createZitadelLoginPolicy(options: ZitadelLoginPolicyOptions): Pick<ZitadelPort, 'closeSelfRegistration'>;
|
|
23
|
+
export declare function createZitadelLoginPolicy(options: ZitadelLoginPolicyOptions): Pick<ZitadelPort, 'closeSelfRegistration' | 'enforceSecondFactor'>;
|
|
@@ -20,6 +20,21 @@ const LOGIN_POLICY_SETTINGS = [
|
|
|
20
20
|
'forceMfaLocalOnly',
|
|
21
21
|
];
|
|
22
22
|
const CALL_TIMEOUT_MS = 30_000;
|
|
23
|
+
/** What {@link ZitadelPort.enforceSecondFactor} sets. The two lists are exact: any other factor (email or SMS code) is removed. */
|
|
24
|
+
const SECOND_FACTOR_SETTINGS = {
|
|
25
|
+
forceMfa: true,
|
|
26
|
+
allowRegister: false,
|
|
27
|
+
allowExternalIdp: false,
|
|
28
|
+
allowUsernamePassword: true,
|
|
29
|
+
passwordlessType: 'PASSWORDLESS_TYPE_ALLOWED',
|
|
30
|
+
mfaInitSkipLifetime: '0s',
|
|
31
|
+
};
|
|
32
|
+
const SECOND_FACTORS = ['SECOND_FACTOR_TYPE_OTP', 'SECOND_FACTOR_TYPE_U2F'];
|
|
33
|
+
const MULTI_FACTORS = ['MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION'];
|
|
34
|
+
/** ZITADEL leaves false booleans and zero durations out of a read. */
|
|
35
|
+
const isZeroDuration = (value) => value === undefined || (typeof value === 'string' && /^0+(\.0+)?s$/.test(value));
|
|
36
|
+
const stringList = (value) => Array.isArray(value) ? value.filter((v) => typeof v === 'string') : [];
|
|
37
|
+
const sameSet = (a, b) => a.length === b.length && b.every((v) => a.includes(v));
|
|
23
38
|
/**
|
|
24
39
|
* The real {@link ZitadelPort.closeSelfRegistration}: the mirror of
|
|
25
40
|
* `ZitadelClient.allowSelfRegistration` in `@wtfalch/providers`, with
|
|
@@ -35,8 +50,7 @@ const CALL_TIMEOUT_MS = 30_000;
|
|
|
35
50
|
*/
|
|
36
51
|
export function createZitadelLoginPolicy(options) {
|
|
37
52
|
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
38
|
-
async function call(method, orgId, body) {
|
|
39
|
-
const path = '/management/v1/policies/login';
|
|
53
|
+
async function call(method, orgId, body, path = '/management/v1/policies/login') {
|
|
40
54
|
const response = await fetchImpl(`${options.url}${path}`, {
|
|
41
55
|
method,
|
|
42
56
|
signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
|
|
@@ -67,7 +81,66 @@ export function createZitadelLoginPolicy(options) {
|
|
|
67
81
|
}
|
|
68
82
|
return data;
|
|
69
83
|
}
|
|
84
|
+
/** Every setting of the policy in force, as the write bodies take them. */
|
|
85
|
+
const settingsOf = (policy) => {
|
|
86
|
+
const settings = {};
|
|
87
|
+
for (const key of LOGIN_POLICY_SETTINGS) {
|
|
88
|
+
if (policy[key] !== undefined)
|
|
89
|
+
settings[key] = policy[key];
|
|
90
|
+
}
|
|
91
|
+
return settings;
|
|
92
|
+
};
|
|
70
93
|
return {
|
|
94
|
+
async enforceSecondFactor(orgId) {
|
|
95
|
+
if (!orgId)
|
|
96
|
+
throw new Error('enforceSecondFactor: orgId is required');
|
|
97
|
+
const data = await call('GET', orgId, undefined);
|
|
98
|
+
const policy = data.policy ?? {};
|
|
99
|
+
const isDefault = data.isDefault === true || policy.isDefault === true;
|
|
100
|
+
const secondFactors = stringList(policy.secondFactors);
|
|
101
|
+
const multiFactors = stringList(policy.multiFactors);
|
|
102
|
+
const want = SECOND_FACTOR_SETTINGS;
|
|
103
|
+
const settingsDiffer = (policy.forceMfa === true) !== want.forceMfa ||
|
|
104
|
+
(policy.allowRegister === true) !== want.allowRegister ||
|
|
105
|
+
(policy.allowExternalIdp === true) !== want.allowExternalIdp ||
|
|
106
|
+
(policy.allowUsernamePassword === true) !== want.allowUsernamePassword ||
|
|
107
|
+
policy.passwordlessType !== want.passwordlessType ||
|
|
108
|
+
!isZeroDuration(policy.mfaInitSkipLifetime);
|
|
109
|
+
const factorsDiffer = !sameSet(secondFactors, SECOND_FACTORS) || !sameSet(multiFactors, MULTI_FACTORS);
|
|
110
|
+
if (!isDefault && !settingsDiffer && !factorsDiffer)
|
|
111
|
+
return { changed: false };
|
|
112
|
+
const settings = { ...settingsOf(policy), ...want };
|
|
113
|
+
if (isDefault) {
|
|
114
|
+
// The org's own policy, written whole: the factor lists go in the same body.
|
|
115
|
+
await call('POST', orgId, {
|
|
116
|
+
...settings,
|
|
117
|
+
secondFactors: SECOND_FACTORS,
|
|
118
|
+
multiFactors: MULTI_FACTORS,
|
|
119
|
+
idps: (policy.idps ?? []).map((idp) => ({
|
|
120
|
+
idpId: idp.idpId,
|
|
121
|
+
ownerType: 'IDP_OWNER_TYPE_SYSTEM',
|
|
122
|
+
})),
|
|
123
|
+
});
|
|
124
|
+
return { changed: true };
|
|
125
|
+
}
|
|
126
|
+
if (settingsDiffer)
|
|
127
|
+
await call('PUT', orgId, settings);
|
|
128
|
+
// Add before remove, so the org is never left with no second factor.
|
|
129
|
+
const factorPath = '/management/v1/policies/login';
|
|
130
|
+
for (const type of SECOND_FACTORS.filter((t) => !secondFactors.includes(t))) {
|
|
131
|
+
await call('POST', orgId, { type }, `${factorPath}/second_factors`);
|
|
132
|
+
}
|
|
133
|
+
for (const type of MULTI_FACTORS.filter((t) => !multiFactors.includes(t))) {
|
|
134
|
+
await call('POST', orgId, { type }, `${factorPath}/multi_factors`);
|
|
135
|
+
}
|
|
136
|
+
for (const type of secondFactors.filter((t) => !SECOND_FACTORS.includes(t))) {
|
|
137
|
+
await call('DELETE', orgId, undefined, `${factorPath}/second_factors/${encodeURIComponent(type)}`);
|
|
138
|
+
}
|
|
139
|
+
for (const type of multiFactors.filter((t) => !MULTI_FACTORS.includes(t))) {
|
|
140
|
+
await call('DELETE', orgId, undefined, `${factorPath}/multi_factors/${encodeURIComponent(type)}`);
|
|
141
|
+
}
|
|
142
|
+
return { changed: true };
|
|
143
|
+
},
|
|
71
144
|
async closeSelfRegistration(orgId) {
|
|
72
145
|
if (!orgId)
|
|
73
146
|
throw new Error('closeSelfRegistration: orgId is required');
|
|
@@ -77,12 +150,7 @@ export function createZitadelLoginPolicy(options) {
|
|
|
77
150
|
if (!isDefault && policy.allowRegister !== true) {
|
|
78
151
|
return { changed: false };
|
|
79
152
|
}
|
|
80
|
-
const settings = {};
|
|
81
|
-
for (const key of LOGIN_POLICY_SETTINGS) {
|
|
82
|
-
if (policy[key] !== undefined)
|
|
83
|
-
settings[key] = policy[key];
|
|
84
|
-
}
|
|
85
|
-
settings.allowRegister = false;
|
|
153
|
+
const settings = { ...settingsOf(policy), allowRegister: false };
|
|
86
154
|
if (isDefault) {
|
|
87
155
|
await call('POST', orgId, {
|
|
88
156
|
...settings,
|
package/dist/platform.d.ts
CHANGED
|
@@ -121,13 +121,44 @@ export interface DeclaredPlatform {
|
|
|
121
121
|
coolify: PlatformService;
|
|
122
122
|
/** foundry#542: the outside prober Worker; see {@link PlatformUptimeWorker}. */
|
|
123
123
|
uptime: PlatformUptimeWorker;
|
|
124
|
+
/**
|
|
125
|
+
* foundry#544: which Healthchecks instance manages the heartbeat checks
|
|
126
|
+
* for every piece of scheduled work foundry sets up (the uptime Worker,
|
|
127
|
+
* each platform database backup), decoupled from whether foundry also
|
|
128
|
+
* deploys the self-hosted server ({@link hc} above). `'self-hosted'`
|
|
129
|
+
* (the default, and today's only behaviour before #544) still requires
|
|
130
|
+
* `hc.enabled` and reads the project API key under
|
|
131
|
+
* `PLATFORM_HC_API_KEY_REF`. `'hosted'` needs no self-hosted server at
|
|
132
|
+
* all -- not `hc.enabled`, not `hc` applied -- and reads its own API key
|
|
133
|
+
* under `PLATFORM_HEARTBEATS_HOSTED_API_KEY_REF` instead; the host's own
|
|
134
|
+
* `HeartbeatsPort` implementation is what actually points at the outside
|
|
135
|
+
* service (healthchecks.io or otherwise) or the self-hosted instance --
|
|
136
|
+
* this field only says which one foundry expects behind it. Changing it
|
|
137
|
+
* re-plans `heartbeats:reconcile`, which creates every desired check
|
|
138
|
+
* fresh on the newly-chosen provider and delivers its ping URLs; a check
|
|
139
|
+
* left behind on the provider no longer chosen is not deleted (foundry
|
|
140
|
+
* never had it in view the moment the switch is declared) -- see
|
|
141
|
+
* issue #544's own "Human steps". Which to use at all, and whether the
|
|
142
|
+
* self-hosted server stays as a standing option, are both still open
|
|
143
|
+
* "Decide" items on issue #544's own thread, not settled by this field.
|
|
144
|
+
*/
|
|
145
|
+
heartbeatsProvider: HeartbeatsProvider;
|
|
124
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* foundry#544: see {@link DeclaredPlatform.heartbeatsProvider}'s own doc
|
|
149
|
+
* comment.
|
|
150
|
+
*/
|
|
151
|
+
export type HeartbeatsProvider = 'self-hosted' | 'hosted';
|
|
152
|
+
/** The only values {@link DeclaredPlatform.heartbeatsProvider} may hold. */
|
|
153
|
+
export declare const HEARTBEATS_PROVIDERS: ReadonlySet<HeartbeatsProvider>;
|
|
125
154
|
/** `<service>.<apex>` -- the default `hostname` for a platform service, before an operator overrides it. */
|
|
126
155
|
export declare function defaultPlatformHostname(service: PlatformServiceKey, apex: string): string;
|
|
127
156
|
/**
|
|
128
157
|
* `null` when `platform` is valid; otherwise the reason it is not, same
|
|
129
158
|
* shape as `org.ts`'s `validateBuyerIdentity`.
|
|
130
159
|
*
|
|
160
|
+
* - `auth`, `mail`, `files`, `jobs` and `sfu` are not `enabled`: the owner company
|
|
161
|
+
* runs them as its own services.
|
|
131
162
|
* - `apex` is a valid domain (`org.ts`'s `validateDomain`).
|
|
132
163
|
* - `serverIpv4`, when declared, is a dotted-quad IPv4 address.
|
|
133
164
|
* - Every service's `hostname` is the apex itself or a subdomain of it -- a
|
|
@@ -137,4 +168,5 @@ export declare function defaultPlatformHostname(service: PlatformServiceKey, ape
|
|
|
137
168
|
* - Every service's `gitRef` is non-empty once that service is `enabled` --
|
|
138
169
|
* an enabled service with nothing to deploy is not yet declared.
|
|
139
170
|
*/
|
|
171
|
+
export declare const OWNER_RUNS_OWN_SERVICES_REASON = "auth, mail, files, jobs and sfu cannot be enabled on the platform: the owner company runs these as its own services";
|
|
140
172
|
export declare function validatePlatform(platform: DeclaredPlatform): string | null;
|
package/dist/platform.js
CHANGED
|
@@ -17,6 +17,11 @@ export const PLATFORM_SERVICE_KEYS = [
|
|
|
17
17
|
'hc',
|
|
18
18
|
'coolify',
|
|
19
19
|
];
|
|
20
|
+
/** The only values {@link DeclaredPlatform.heartbeatsProvider} may hold. */
|
|
21
|
+
export const HEARTBEATS_PROVIDERS = new Set([
|
|
22
|
+
'self-hosted',
|
|
23
|
+
'hosted',
|
|
24
|
+
]);
|
|
20
25
|
/** `<service>.<apex>` -- the default `hostname` for a platform service, before an operator overrides it. */
|
|
21
26
|
export function defaultPlatformHostname(service, apex) {
|
|
22
27
|
return `${service}.${apex}`;
|
|
@@ -28,6 +33,8 @@ function serviceOf(platform, key) {
|
|
|
28
33
|
* `null` when `platform` is valid; otherwise the reason it is not, same
|
|
29
34
|
* shape as `org.ts`'s `validateBuyerIdentity`.
|
|
30
35
|
*
|
|
36
|
+
* - `auth`, `mail`, `files`, `jobs` and `sfu` are not `enabled`: the owner company
|
|
37
|
+
* runs them as its own services.
|
|
31
38
|
* - `apex` is a valid domain (`org.ts`'s `validateDomain`).
|
|
32
39
|
* - `serverIpv4`, when declared, is a dotted-quad IPv4 address.
|
|
33
40
|
* - Every service's `hostname` is the apex itself or a subdomain of it -- a
|
|
@@ -37,7 +44,13 @@ function serviceOf(platform, key) {
|
|
|
37
44
|
* - Every service's `gitRef` is non-empty once that service is `enabled` --
|
|
38
45
|
* an enabled service with nothing to deploy is not yet declared.
|
|
39
46
|
*/
|
|
47
|
+
export const OWNER_RUNS_OWN_SERVICES_REASON = 'auth, mail, files, jobs and sfu cannot be enabled on the platform: the owner company runs these as its own services';
|
|
48
|
+
/** The platform services the owner company runs as its own, which the platform keeps off. */
|
|
49
|
+
const OWNER_RUN_SERVICE_KEYS = ['auth', 'mail', 'files', 'jobs', 'sfu'];
|
|
40
50
|
export function validatePlatform(platform) {
|
|
51
|
+
if (OWNER_RUN_SERVICE_KEYS.some((key) => platform[key].enabled)) {
|
|
52
|
+
return OWNER_RUNS_OWN_SERVICES_REASON;
|
|
53
|
+
}
|
|
41
54
|
const apexReason = validateDomain(platform.apex);
|
|
42
55
|
if (apexReason)
|
|
43
56
|
return `apex invalid: ${apexReason}`;
|
|
@@ -75,5 +88,8 @@ export function validatePlatform(platform) {
|
|
|
75
88
|
if (platform.mail.lettermintProjectId != null && !platform.mail.lettermintProjectId.trim()) {
|
|
76
89
|
return 'mail.lettermintProjectId must not be empty when set';
|
|
77
90
|
}
|
|
91
|
+
if (!HEARTBEATS_PROVIDERS.has(platform.heartbeatsProvider)) {
|
|
92
|
+
return `heartbeatsProvider '${platform.heartbeatsProvider}' must be one of ${[...HEARTBEATS_PROVIDERS].join(', ')}`;
|
|
93
|
+
}
|
|
78
94
|
return null;
|
|
79
95
|
}
|
package/dist/provision.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export declare const DATABASE_NAME = "foundry";
|
|
|
10
10
|
* still satisfied with no adapter.
|
|
11
11
|
*/
|
|
12
12
|
export interface ProvisionCoolifyPort extends Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'listDatabases' | 'createPostgresDatabase'> {
|
|
13
|
+
/** With `raw`, the database is created on the platform destination (`plan/destinations.ts`). */
|
|
14
|
+
raw?: CoolifyPort['raw'];
|
|
13
15
|
listEnvironments(projectUuid: string): Promise<Array<{
|
|
14
16
|
id: number;
|
|
15
17
|
uuid: string;
|
package/dist/provision.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { placedOnPlatform } from './plan/destinations.js';
|
|
1
2
|
// #R2 (map gap: foundry's own Postgres, two commands): find-or-create the
|
|
2
3
|
// Postgres database `scripts/foundry-db-provision.mjs` migrates into.
|
|
3
4
|
// Project, environment and database names are fixed here rather than taken
|
|
@@ -56,7 +57,8 @@ export async function applyProvision(coolify, plan) {
|
|
|
56
57
|
// `createPostgresDatabase` also returns `internal_db_url` -- a live
|
|
57
58
|
// credential -- which is deliberately never read off this result: only
|
|
58
59
|
// `uuid` (and the `name` we already know) leaves this function.
|
|
59
|
-
const
|
|
60
|
+
const creator = placedOnPlatform(coolify);
|
|
61
|
+
const created = await creator.createPostgresDatabase({
|
|
60
62
|
project_uuid: project.uuid,
|
|
61
63
|
server_uuid: server.uuid,
|
|
62
64
|
environment_name: environment.name,
|
package/dist/slug.d.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/** The flattened set {@link validateSlug} checks a candidate slug against. */
|
|
2
2
|
export declare const RESERVED_SLUGS: ReadonlySet<string>;
|
|
3
|
+
/** Every platform app, tunnel, project and database name starts `platform-`, so no company slug may. */
|
|
4
|
+
export declare const RESERVED_SLUG_PREFIX = "platform-";
|
|
3
5
|
/**
|
|
4
6
|
* Why {@link validateSlug} refused a candidate slug, by name so a caller —
|
|
5
7
|
* `store/postgres.ts`'s `DeclaredOrgError`, eventually the operator page —
|
|
6
8
|
* can report it without parsing `message` text.
|
|
7
9
|
*/
|
|
8
|
-
export type SlugRejectionReason = 'invalid-label' | 'reserved' | 'environment-suffix';
|
|
10
|
+
export type SlugRejectionReason = 'invalid-label' | 'reserved' | 'environment-suffix' | 'reserved-prefix';
|
|
9
11
|
export interface SlugRejection {
|
|
10
12
|
reason: SlugRejectionReason;
|
|
11
13
|
message: string;
|
package/dist/slug.js
CHANGED
|
@@ -105,6 +105,8 @@ export const RESERVED_SLUGS = new Set([
|
|
|
105
105
|
...RESERVED_INFRASTRUCTURE_AND_MAIL,
|
|
106
106
|
...RESERVED_AUTH_AND_ENVIRONMENTS,
|
|
107
107
|
]);
|
|
108
|
+
/** Every platform app, tunnel, project and database name starts `platform-`, so no company slug may. */
|
|
109
|
+
export const RESERVED_SLUG_PREFIX = 'platform-';
|
|
108
110
|
/**
|
|
109
111
|
* A DNS label: 1-63 characters, lowercase `a-z`, `0-9` and hyphen, no
|
|
110
112
|
* leading or trailing hyphen. Consecutive hyphens are legal DNS (RFC 1035
|
|
@@ -136,6 +138,19 @@ const AGORA_SUFFIX = /-agora$/;
|
|
|
136
138
|
* one that opts in.
|
|
137
139
|
*/
|
|
138
140
|
const BOULE_SUFFIX = /-boule$/;
|
|
141
|
+
/** `<something>-auth|-mail|-files|-sfu|-cms` — an own company's service hostnames on oikos.host (`plan/own-hostnames.ts`). */
|
|
142
|
+
const OWN_SERVICE_SUFFIX = /-(auth|mail|files|sfu|cms)$/;
|
|
143
|
+
/**
|
|
144
|
+
* `names` itself, or `names-<anything>` — foundry names the company tunnel
|
|
145
|
+
* `${slug}-tunnel` (`plan/executor.ts`), so a slug of `names` or starting
|
|
146
|
+
* `names-` produces a tunnel name starting `names-`. Edge refuses to create
|
|
147
|
+
* a platform tunnel whose name starts with `names-` and reads a live
|
|
148
|
+
* `names-*` tunnel as its own, not a company's (foundry#535; `edge`'s
|
|
149
|
+
* `packages/service/src/platform-tunnels.ts`). `namesake` is not a near
|
|
150
|
+
* miss: its tunnel name is `namesake-tunnel`, which does not start with
|
|
151
|
+
* `names-`.
|
|
152
|
+
*/
|
|
153
|
+
const NAMES_PREFIX = /^names(-|$)/;
|
|
139
154
|
/**
|
|
140
155
|
* Validates a candidate org slug against every rule #28 decided. Returns
|
|
141
156
|
* `null` when the slug is acceptable, or a {@link SlugRejection} naming
|
|
@@ -150,20 +165,27 @@ export function validateSlug(slug) {
|
|
|
150
165
|
message: `slug '${slug}' is not a valid DNS label: must be 1-63 characters, lowercase a-z, 0-9 and hyphen, and may not start or end with a hyphen`,
|
|
151
166
|
};
|
|
152
167
|
}
|
|
153
|
-
if (RESERVED_SLUGS.has(slug)) {
|
|
168
|
+
if (RESERVED_SLUGS.has(slug) || slug.startsWith(RESERVED_SLUG_PREFIX)) {
|
|
154
169
|
return {
|
|
155
170
|
reason: 'reserved',
|
|
156
171
|
message: `slug '${slug}' is reserved by the estate (#28) and cannot be taken by an org`,
|
|
157
172
|
};
|
|
158
173
|
}
|
|
174
|
+
if (NAMES_PREFIX.test(slug)) {
|
|
175
|
+
return {
|
|
176
|
+
reason: 'reserved-prefix',
|
|
177
|
+
message: `slug '${slug}' is 'names' or starts with 'names-', a prefix edge reserves for its own platform tunnels and refuses to create a company tunnel under (#535)`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
159
180
|
if (STAGE_SUFFIX.test(slug) ||
|
|
160
181
|
PR_SUFFIX.test(slug) ||
|
|
161
182
|
CALLS_SUFFIX.test(slug) ||
|
|
162
183
|
AGORA_SUFFIX.test(slug) ||
|
|
163
|
-
BOULE_SUFFIX.test(slug)
|
|
184
|
+
BOULE_SUFFIX.test(slug) ||
|
|
185
|
+
OWN_SERVICE_SUFFIX.test(slug)) {
|
|
164
186
|
return {
|
|
165
187
|
reason: 'environment-suffix',
|
|
166
|
-
message: `slug '${slug}' ends with an environment suffix ('-stage', '-pr-<digits>', '-calls', '-agora' or '-
|
|
188
|
+
message: `slug '${slug}' ends with an environment suffix ('-stage', '-pr-<digits>', '-calls', '-agora', '-boule', '-auth', '-mail', '-files', '-sfu' or '-cms') reserved for another org's own hostname (#28, #145, #164, #161) — 'acme-calls' is only legal as acme's calls hostname, 'acme-agora' only as acme's Agora hostname, 'acme-boule' only as acme's Boule hostname, never as an org slug in its own right`,
|
|
167
189
|
};
|
|
168
190
|
}
|
|
169
191
|
return null;
|