@hearthlog/tooling 0.0.0-stage → 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 +235 -0
- package/bin/hearthlog.mjs +52 -0
- package/package.json +64 -3
- package/src/apply.ts +73 -0
- package/src/check/check-layers.ts +118 -0
- package/src/check/check-modules.ts +198 -0
- package/src/check/check-sdk-exports.ts +79 -0
- package/src/check/check-tenant-config.ts +208 -0
- package/src/cli/hearthlog.ts +212 -0
- package/src/cli/list-sites.ts +15 -0
- package/src/compose/compose-modules.ts +490 -0
- package/src/compose/module-scaffold.ts +194 -0
- package/src/deploy/artifact-compare.ts +93 -0
- package/src/deploy/artifact.ts +127 -0
- package/src/deploy/async-utils.ts +71 -0
- package/src/deploy/backup-tenants.ts +139 -0
- package/src/deploy/cloudflare-access.ts +789 -0
- package/src/deploy/copy-tenant.ts +395 -0
- package/src/deploy/dedicated-ledger.ts +77 -0
- package/src/deploy/deploy-ledger.ts +162 -0
- package/src/deploy/deploy-runner.ts +240 -0
- package/src/deploy/deploy-tenant.ts +170 -0
- package/src/deploy/deployment-security.ts +141 -0
- package/src/deploy/ensure-tenant.ts +988 -0
- package/src/deploy/event-policy.ts +23 -0
- package/src/deploy/frontend-dry-run.ts +18 -0
- package/src/deploy/ledger-credential.ts +48 -0
- package/src/deploy/ledger-scripts.ts +13 -0
- package/src/deploy/split-tenant.ts +295 -0
- package/src/dev/local-development.ts +288 -0
- package/src/dev/provision-local.ts +13 -0
- package/src/generate/build-generated.ts +166 -0
- package/src/generate/generate-brand-icons.ts +128 -0
- package/src/generate/generate-entities.ts +627 -0
- package/src/generate/generate-tenant.ts +161 -0
- package/src/host/core-routes.ts +39 -0
- package/src/host/write-host.ts +119 -0
- package/src/index.ts +8 -0
- package/src/init/check-module.ts +143 -0
- package/src/init/export-tenant.ts +124 -0
- package/src/init/init.ts +167 -0
- package/src/move.ts +189 -0
- package/src/plan.ts +160 -0
- package/src/public-workers/build-gallery-public.ts +114 -0
- package/src/public-workers/calendar-public-config.ts +27 -0
- package/src/public-workers/check-calendar-public-route.ts +41 -0
- package/src/public-workers/check-gallery-public-route.ts +43 -0
- package/src/public-workers/gallery-public-config.ts +22 -0
- package/src/receipt.ts +74 -0
- package/src/root.ts +16 -0
- package/src/security/audit-push.ts +136 -0
- package/src/security/forbidden-identities.ts +107 -0
- package/src/security/forbidden-init.ts +88 -0
- package/src/tenants/host-configs.ts +96 -0
- package/src/tenants/ledger-config.ts +57 -0
- package/src/tenants/lock-file.ts +45 -0
- package/src/tenants/module-lock.ts +58 -0
- package/src/tenants/tenant-config.ts +887 -0
- package/src/tenants/tenant-copy-plan.ts +119 -0
- package/src/tenants/tenant-schema.ts +171 -0
- package/src/tenants/tenant-wrangler.ts +37 -0
- package/src/tenants/wrangler-base.ts +50 -0
- package/src/tenants/wrangler-config.ts +266 -0
- package/src/vite/config.ts +310 -0
- package/src/vite/dev-plugins.ts +338 -0
- package/src/vite/vite-plugin-entity-gen.ts +70 -0
- package/src/vite/vite-plugin-tenant-manifest.ts +61 -0
- package/src/vite/vite-plugin-worker-assets.ts +42 -0
- package/templates/host/app.d.ts.txt +31 -0
- package/templates/host/microlighter.d.ts.txt +11 -0
- package/templates/module/ci.yml.tmpl +44 -0
- package/templates/module/gitignore.tmpl +7 -0
- package/templates/module/release.yml.tmpl +53 -0
- package/templates/tenant/README.md.tmpl +13 -0
- package/templates/tenant/generated-package.json.tmpl +16 -0
- package/templates/tenant/github-workflows/deploy.yml.tmpl +110 -0
- package/templates/tenant/gitignore.tmpl +11 -0
- package/templates/tenant/package.json.tmpl +11 -0
- package/templates/tenant/patches/sqlocal@0.18.0.patch +18 -0
- package/templates/tenant/pnpm-workspace.yaml.tmpl +8 -0
- package/templates/tenant/tenants/__id__/tenant.json.tmpl +8 -0
- package/templates/tenant/tenants.json.tmpl +17 -0
- package/templates/tenant/tsconfig.json.tmpl +21 -0
- package/templates/tenant/vite.config.ts.tmpl +5 -0
- package/README.md +0 -3
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Zero Trust Access planning and API access for tenant
|
|
3
|
+
* provisioning.
|
|
4
|
+
*
|
|
5
|
+
* `planAccessApps` is a pure function (unit-tested) that decides, for every
|
|
6
|
+
* tenant hostname, whether an Access application can be adopted or must be
|
|
7
|
+
* created, and whether the owner Allow policy is already present.
|
|
8
|
+
* `CloudflareAccessClient` is the thin REST runner for the decisions the
|
|
9
|
+
* planner makes (account-scoped `/access/apps` endpoints).
|
|
10
|
+
*
|
|
11
|
+
* API token requirements (Account scoped):
|
|
12
|
+
* - Access: Apps and Policies -> Edit
|
|
13
|
+
* - Access: Service Tokens -> Edit (only for sites with integrations)
|
|
14
|
+
* - Account Settings -> Read (only for automatic account-id discovery)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface AccessSiteInput {
|
|
18
|
+
id: string;
|
|
19
|
+
title: string;
|
|
20
|
+
hostname: string;
|
|
21
|
+
/** Every email that must be allowed through the Access application. */
|
|
22
|
+
authorizedEmails: readonly string[];
|
|
23
|
+
/** Ids of the site's service integrations; any makes it need the integrations application. */
|
|
24
|
+
integrations?: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ExistingAccessApp {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
domain: string;
|
|
31
|
+
aud: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ExistingAccessPolicy {
|
|
35
|
+
id: string;
|
|
36
|
+
name: string;
|
|
37
|
+
decision: string;
|
|
38
|
+
include: unknown[];
|
|
39
|
+
/**
|
|
40
|
+
* Account-level (reusable) policy. The app-scoped endpoints refuse to
|
|
41
|
+
* update or delete it (error 12130); those operations go through
|
|
42
|
+
* `/accounts/{account_id}/access/policies/{policy_id}`. Resolved from the
|
|
43
|
+
* app-scoped response when it carries the flag and otherwise from the
|
|
44
|
+
* account-level registry of reusable policies.
|
|
45
|
+
*/
|
|
46
|
+
reusable?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type AccessAction =
|
|
50
|
+
| {
|
|
51
|
+
kind: "adopt-app";
|
|
52
|
+
siteId: string;
|
|
53
|
+
appId: string;
|
|
54
|
+
aud: string;
|
|
55
|
+
name: string;
|
|
56
|
+
}
|
|
57
|
+
| { kind: "create-app"; siteId: string; hostname: string; name: string }
|
|
58
|
+
| {
|
|
59
|
+
kind: "reconcile-policy";
|
|
60
|
+
siteId: string;
|
|
61
|
+
appId: string;
|
|
62
|
+
/**
|
|
63
|
+
* Existing pure-email Allow policies to fold into `emails`. The
|
|
64
|
+
* first is updated in place; any others are deleted. Empty means a
|
|
65
|
+
* new policy must be created.
|
|
66
|
+
*/
|
|
67
|
+
replacePolicyIds: string[];
|
|
68
|
+
/** The complete authorized email list, lower-cased and deduped. */
|
|
69
|
+
emails: string[];
|
|
70
|
+
/** Authorized emails not granted by any current Allow policy. */
|
|
71
|
+
missingEmails: string[];
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const ALLOW_POLICY_NAME = "Site access (allow)";
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Calendar feeds used to be served from the site itself, behind a path-scoped
|
|
78
|
+
* Bypass application. They now live on their own Worker, so this application
|
|
79
|
+
* is retired.
|
|
80
|
+
*/
|
|
81
|
+
export const FEED_PATH_SUFFIX = "/api/calendar-feed/*";
|
|
82
|
+
|
|
83
|
+
/** What the CLOUDFLARE_API_TOKEN must allow for the Access phase to run. */
|
|
84
|
+
export const ACCESS_TOKEN_REQUIREMENT =
|
|
85
|
+
'"Access: Apps and Policies -> Edit" permission, plus "Access: Service Tokens -> Edit" for integrations' +
|
|
86
|
+
' (plus "Account Settings -> Read" unless CLOUDFLARE_ACCOUNT_ID is set)';
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Applications protect `host` or `host/path`; only an application whose
|
|
90
|
+
* domain is exactly the bare hostname protects the whole site.
|
|
91
|
+
*/
|
|
92
|
+
function bareHost(domain: string): string {
|
|
93
|
+
return domain
|
|
94
|
+
.trim()
|
|
95
|
+
.toLowerCase()
|
|
96
|
+
.replace(/^https?:\/\//, "")
|
|
97
|
+
.split("/")[0] ?? "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Union of the emails granted by the application's Allow policies. */
|
|
101
|
+
function grantedEmails(policies: readonly ExistingAccessPolicy[]): Set<string> {
|
|
102
|
+
const granted = new Set<string>();
|
|
103
|
+
for (const policy of policies) {
|
|
104
|
+
if (policy.decision.toLowerCase() !== "allow") continue;
|
|
105
|
+
for (const email of extractPolicyEmails(policy.include)) granted.add(email);
|
|
106
|
+
}
|
|
107
|
+
return granted;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function extractPolicyEmails(include: unknown[]): string[] {
|
|
111
|
+
const emails: string[] = [];
|
|
112
|
+
for (const entry of include) {
|
|
113
|
+
const record = entry as { email?: { email?: unknown } };
|
|
114
|
+
const email = record?.email?.email;
|
|
115
|
+
if (typeof email === "string") emails.push(email.toLowerCase());
|
|
116
|
+
}
|
|
117
|
+
return emails;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isEmailEntry(entry: unknown): boolean {
|
|
121
|
+
const record = entry as { email?: { email?: unknown } };
|
|
122
|
+
return typeof record?.email?.email === "string";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* An Allow policy we manage: every `include` entry is an email rule. Policies
|
|
127
|
+
* that mix email rules with other selectors (IP, group, everyone, …) are left
|
|
128
|
+
* untouched so manual access rules survive reconciliation.
|
|
129
|
+
*/
|
|
130
|
+
function isPureEmailAllowPolicy(policy: ExistingAccessPolicy): boolean {
|
|
131
|
+
return (
|
|
132
|
+
policy.decision.toLowerCase() === "allow" &&
|
|
133
|
+
Array.isArray(policy.include) &&
|
|
134
|
+
policy.include.length > 0 &&
|
|
135
|
+
policy.include.every(isEmailEntry)
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Trim, lower-case, drop blanks, and dedupe while preserving declaration order. */
|
|
140
|
+
function normalizeEmailList(emails: readonly string[]): string[] {
|
|
141
|
+
const seen = new Set<string>();
|
|
142
|
+
const result: string[] = [];
|
|
143
|
+
for (const email of emails) {
|
|
144
|
+
const normalized = email.trim().toLowerCase();
|
|
145
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
146
|
+
seen.add(normalized);
|
|
147
|
+
result.push(normalized);
|
|
148
|
+
}
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Decide the access actions for every site. Applications are matched by
|
|
154
|
+
* exact bare hostname (path-scoped applications such as an admin-path app on
|
|
155
|
+
* the same hostname are ignored); when several applications share a hostname
|
|
156
|
+
* the one whose name equals the site title is preferred. A matched
|
|
157
|
+
* application is always reported as `adopt-app` (its aud is required for the
|
|
158
|
+
* worker secret). The app's pure-email Allow policies are reconciled to
|
|
159
|
+
* exactly `authorizedEmails`: a removed email drops out (the policy is
|
|
160
|
+
* rewritten and redundant policies deleted), an added email appears, and a
|
|
161
|
+
* satisfied set produces no policy action at all.
|
|
162
|
+
*/
|
|
163
|
+
export function planAccessApps(
|
|
164
|
+
sites: readonly AccessSiteInput[],
|
|
165
|
+
existingApps: readonly ExistingAccessApp[],
|
|
166
|
+
policiesByAppId: ReadonlyMap<string, readonly ExistingAccessPolicy[]>,
|
|
167
|
+
): AccessAction[] {
|
|
168
|
+
const actions: AccessAction[] = [];
|
|
169
|
+
|
|
170
|
+
for (const site of sites) {
|
|
171
|
+
const hostname = bareHost(site.hostname);
|
|
172
|
+
const candidates = existingApps.filter(
|
|
173
|
+
(app) => app.domain.trim().toLowerCase() === hostname,
|
|
174
|
+
);
|
|
175
|
+
const app =
|
|
176
|
+
candidates.find((candidate) => candidate.name === site.title) ??
|
|
177
|
+
candidates[0];
|
|
178
|
+
|
|
179
|
+
if (!app) {
|
|
180
|
+
actions.push({
|
|
181
|
+
kind: "create-app",
|
|
182
|
+
siteId: site.id,
|
|
183
|
+
hostname,
|
|
184
|
+
name: site.title,
|
|
185
|
+
});
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const policies = policiesByAppId.get(app.id) ?? [];
|
|
190
|
+
const managed = policies.filter(isPureEmailAllowPolicy);
|
|
191
|
+
const managedEmails = new Set<string>();
|
|
192
|
+
for (const policy of managed) {
|
|
193
|
+
for (const email of extractPolicyEmails(policy.include)) {
|
|
194
|
+
managedEmails.add(email);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const desired = normalizeEmailList(site.authorizedEmails);
|
|
198
|
+
const granted = grantedEmails(policies);
|
|
199
|
+
const missingEmails = desired.filter((email) => !granted.has(email));
|
|
200
|
+
const emailsDiffer =
|
|
201
|
+
desired.length !== managedEmails.size ||
|
|
202
|
+
desired.some((email) => !managedEmails.has(email));
|
|
203
|
+
if (managed.length === 0 || emailsDiffer) {
|
|
204
|
+
actions.push({
|
|
205
|
+
kind: "reconcile-policy",
|
|
206
|
+
siteId: site.id,
|
|
207
|
+
appId: app.id,
|
|
208
|
+
replacePolicyIds: managed.map((policy) => policy.id),
|
|
209
|
+
emails: desired,
|
|
210
|
+
missingEmails,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
actions.push({
|
|
214
|
+
kind: "adopt-app",
|
|
215
|
+
siteId: site.id,
|
|
216
|
+
appId: app.id,
|
|
217
|
+
aud: app.aud,
|
|
218
|
+
name: app.name,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return actions;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Exact path-scoped domain of the retired calendar-feed Bypass application. */
|
|
226
|
+
export function feedAppDomain(hostname: string): string {
|
|
227
|
+
return `${bareHost(hostname)}${FEED_PATH_SUFFIX}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The retired Bypass applications of a site, which `ensure:tenant` deletes. Only
|
|
232
|
+
* applications on the exact feed domain are returned; the site's own and its
|
|
233
|
+
* integrations application are never touched.
|
|
234
|
+
*/
|
|
235
|
+
export function planFeedBypassRemoval(
|
|
236
|
+
site: Pick<AccessSiteInput, "hostname">,
|
|
237
|
+
existingApps: readonly ExistingAccessApp[],
|
|
238
|
+
): ExistingAccessApp[] {
|
|
239
|
+
const domain = feedAppDomain(site.hostname);
|
|
240
|
+
return existingApps.filter((candidate) => candidate.domain.trim().toLowerCase() === domain);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The service-ingest path: callers present an Access service token, never a login. */
|
|
244
|
+
export const INTEGRATIONS_PATH_SUFFIX = "/api/integrations/*";
|
|
245
|
+
export const INTEGRATIONS_POLICY_NAME = "Service integrations (service auth)";
|
|
246
|
+
|
|
247
|
+
/** Exact path-scoped domain of a site's integrations Access application. */
|
|
248
|
+
export function integrationsAppDomain(hostname: string): string {
|
|
249
|
+
return `${bareHost(hostname)}${INTEGRATIONS_PATH_SUFFIX}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export type IntegrationsAccessAction =
|
|
253
|
+
| { kind: "create-integrations-app"; siteId: string; hostname: string; domain: string; name: string }
|
|
254
|
+
| { kind: "adopt-integrations-app"; siteId: string; appId: string; aud: string };
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Plan the path-scoped application for service ingest. It is separate from the
|
|
258
|
+
* site application on purpose: its audience differs, so a service-token JWT is
|
|
259
|
+
* useless on every other route, and Access itself refuses the token elsewhere.
|
|
260
|
+
* Null when the site has no integrations.
|
|
261
|
+
*/
|
|
262
|
+
export function planIntegrationsApp(
|
|
263
|
+
site: AccessSiteInput,
|
|
264
|
+
existingApps: readonly ExistingAccessApp[],
|
|
265
|
+
): IntegrationsAccessAction | null {
|
|
266
|
+
if (!site.integrations || site.integrations.length === 0) return null;
|
|
267
|
+
const domain = integrationsAppDomain(site.hostname);
|
|
268
|
+
const app = existingApps.find((candidate) => candidate.domain.trim().toLowerCase() === domain);
|
|
269
|
+
if (app) return { kind: "adopt-integrations-app", siteId: site.id, appId: app.id, aud: app.aud };
|
|
270
|
+
return {
|
|
271
|
+
kind: "create-integrations-app",
|
|
272
|
+
siteId: site.id,
|
|
273
|
+
hostname: bareHost(site.hostname),
|
|
274
|
+
domain,
|
|
275
|
+
name: `${site.title} integrations`,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export interface ServiceToken {
|
|
280
|
+
/** The token's own id, which policies reference. */
|
|
281
|
+
id: string;
|
|
282
|
+
name: string;
|
|
283
|
+
/** What callers send as CF-Access-Client-Id and what the JWT carries as common_name. */
|
|
284
|
+
clientId: string;
|
|
285
|
+
expiresAt: string | null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export type ServicePolicyAction =
|
|
289
|
+
| { kind: "none" }
|
|
290
|
+
| { kind: "create"; tokenIds: string[] }
|
|
291
|
+
| { kind: "update"; policyId: string; tokenIds: string[]; dropPolicyIds: string[] };
|
|
292
|
+
|
|
293
|
+
/** A policy this tool manages: Service Auth whose every rule names a service token. */
|
|
294
|
+
export function isServiceAuthPolicy(policy: ExistingAccessPolicy): boolean {
|
|
295
|
+
return (
|
|
296
|
+
policy.decision.toLowerCase() === "non_identity" &&
|
|
297
|
+
policy.include.length > 0 &&
|
|
298
|
+
policy.include.every((entry) => {
|
|
299
|
+
const record = entry as { service_token?: unknown };
|
|
300
|
+
return record !== null && typeof record === "object" && "service_token" in record;
|
|
301
|
+
})
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function serviceAuthTokenIds(policy: ExistingAccessPolicy): string[] {
|
|
306
|
+
return policy.include.flatMap((entry) => {
|
|
307
|
+
const token = (entry as { service_token?: { token_id?: unknown } }).service_token;
|
|
308
|
+
return typeof token?.token_id === "string" ? [token.token_id] : [];
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Reconcile the Service Auth policy to exactly the wanted tokens: a removed
|
|
314
|
+
* integration loses access on the next run. With no tokens there is no policy,
|
|
315
|
+
* and an application without one admits nobody.
|
|
316
|
+
*/
|
|
317
|
+
export function planServiceAuthPolicy(
|
|
318
|
+
wantedTokenIds: readonly string[],
|
|
319
|
+
policies: readonly ExistingAccessPolicy[],
|
|
320
|
+
): ServicePolicyAction {
|
|
321
|
+
const wanted = [...new Set(wantedTokenIds)].sort();
|
|
322
|
+
const managed = policies.filter(isServiceAuthPolicy);
|
|
323
|
+
if (wanted.length === 0) {
|
|
324
|
+
return managed.length === 0
|
|
325
|
+
? { kind: "none" }
|
|
326
|
+
: { kind: "update", policyId: managed[0]!.id, tokenIds: [], dropPolicyIds: managed.slice(1).map((policy) => policy.id) };
|
|
327
|
+
}
|
|
328
|
+
const [keep, ...drop] = managed;
|
|
329
|
+
if (!keep) return { kind: "create", tokenIds: wanted };
|
|
330
|
+
const current = [...new Set(serviceAuthTokenIds(keep))].sort();
|
|
331
|
+
if (drop.length === 0 && current.join() === wanted.join()) return { kind: "none" };
|
|
332
|
+
return { kind: "update", policyId: keep.id, tokenIds: wanted, dropPolicyIds: drop.map((policy) => policy.id) };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export class CloudflareAccessClient {
|
|
336
|
+
private readonly base = "https://api.cloudflare.com/client/v4";
|
|
337
|
+
private accountIdPromise: Promise<string> | undefined;
|
|
338
|
+
|
|
339
|
+
constructor(
|
|
340
|
+
private readonly token: string,
|
|
341
|
+
private readonly accountId: string | null,
|
|
342
|
+
private readonly fetchRequest: typeof fetch = globalThis.fetch,
|
|
343
|
+
) {}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Read before updating: preserve unrelated settings and never recreate identities.
|
|
347
|
+
* `resource` is `organizations`, `apps/{id}`, or a policy resource: a
|
|
348
|
+
* reusable policy is addressed as `policies/{id}` (account scope) and an
|
|
349
|
+
* app-scoped one as `apps/{id}/policies/{id}`.
|
|
350
|
+
*/
|
|
351
|
+
async reconcileSessionDuration(
|
|
352
|
+
resource: string,
|
|
353
|
+
duration: string,
|
|
354
|
+
dryRun: boolean,
|
|
355
|
+
expectedAuthDomain?: string,
|
|
356
|
+
): Promise<boolean> {
|
|
357
|
+
const accountId = await this.resolveAccountId();
|
|
358
|
+
const path = `/accounts/${accountId}/access/${resource}`;
|
|
359
|
+
const current = await this.request<Record<string, unknown>>('GET', path);
|
|
360
|
+
if (expectedAuthDomain) {
|
|
361
|
+
let authHostname: string;
|
|
362
|
+
try {
|
|
363
|
+
authHostname = new URL(expectedAuthDomain).hostname;
|
|
364
|
+
} catch {
|
|
365
|
+
throw new Error('infrastructure.accessTeamDomain is not a valid URL');
|
|
366
|
+
}
|
|
367
|
+
if (current.auth_domain !== authHostname) {
|
|
368
|
+
throw new Error('The Access organization does not match infrastructure.accessTeamDomain');
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (current.session_duration === duration) return false;
|
|
372
|
+
if (dryRun) {
|
|
373
|
+
console.log(`[dry-run] Would set Access ${resource} session duration to ${duration}`);
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
// The organization PUT requires its existing auth domain in practice,
|
|
377
|
+
// despite the schema marking it optional. Keep its name too, but do
|
|
378
|
+
// not replay null/default authentication settings from the GET response.
|
|
379
|
+
const body: Record<string, unknown> = resource === 'organizations'
|
|
380
|
+
? {
|
|
381
|
+
auth_domain: current.auth_domain,
|
|
382
|
+
name: current.name,
|
|
383
|
+
session_duration: duration,
|
|
384
|
+
}
|
|
385
|
+
: { ...current, session_duration: duration };
|
|
386
|
+
// Response-only fields are not part of the update contract. Account
|
|
387
|
+
// policies carry a few more, and precedence is app-scoped only.
|
|
388
|
+
const responseOnly = ['id', 'aud', 'created_at', 'updated_at', 'reusable'];
|
|
389
|
+
if (resource.startsWith('policies/')) {
|
|
390
|
+
responseOnly.push('account_id', 'app_count', 'precedence');
|
|
391
|
+
}
|
|
392
|
+
for (const key of responseOnly) {
|
|
393
|
+
delete body[key];
|
|
394
|
+
}
|
|
395
|
+
// Reference existing policies instead of submitting inline policy definitions.
|
|
396
|
+
if (Array.isArray(body.policies)) {
|
|
397
|
+
body.policies = body.policies.map((policy: string | { id: string; precedence?: number }) =>
|
|
398
|
+
typeof policy === 'string' ? policy : {
|
|
399
|
+
id: policy.id,
|
|
400
|
+
...(policy.precedence === undefined ? {} : { precedence: policy.precedence }),
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
await this.request('PUT', path, body);
|
|
404
|
+
console.log(`Set Access ${resource} session duration to ${duration}`);
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Resolve the account id from env or (as fallback) the API itself. */
|
|
409
|
+
async resolveAccountId(): Promise<string> {
|
|
410
|
+
if (this.accountId) return this.accountId;
|
|
411
|
+
this.accountIdPromise ??= this.discoverAccountId();
|
|
412
|
+
return await this.accountIdPromise;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private async discoverAccountId(): Promise<string> {
|
|
416
|
+
const accounts = await this.request<
|
|
417
|
+
Array<{ id: string; name: string }>
|
|
418
|
+
>("GET", "/accounts?per_page=5");
|
|
419
|
+
if (!Array.isArray(accounts) || accounts.length === 0) {
|
|
420
|
+
throw new Error(
|
|
421
|
+
"The API token grants no account access; set CLOUDFLARE_ACCOUNT_ID. " +
|
|
422
|
+
`Response: ${JSON.stringify(accounts).slice(0, 300)}`,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
if (accounts.length > 1) {
|
|
426
|
+
console.warn(
|
|
427
|
+
`Multiple accounts are visible; using "${accounts[0].name}". ` +
|
|
428
|
+
"Set CLOUDFLARE_ACCOUNT_ID to override.",
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
return accounts[0].id;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async listApps(): Promise<ExistingAccessApp[]> {
|
|
435
|
+
const accountId = await this.resolveAccountId();
|
|
436
|
+
const apps: ExistingAccessApp[] = [];
|
|
437
|
+
for (let page = 1; ; page += 1) {
|
|
438
|
+
const pageApps = await this.request<
|
|
439
|
+
Array<{
|
|
440
|
+
id: string;
|
|
441
|
+
name: string;
|
|
442
|
+
domain: string;
|
|
443
|
+
aud: string;
|
|
444
|
+
}>
|
|
445
|
+
>(
|
|
446
|
+
"GET",
|
|
447
|
+
`/accounts/${accountId}/access/apps?per_page=100&page=${page}`,
|
|
448
|
+
);
|
|
449
|
+
if (!Array.isArray(pageApps)) {
|
|
450
|
+
throw new Error(
|
|
451
|
+
`Unexpected Access applications response shape: ` +
|
|
452
|
+
`${JSON.stringify(pageApps).slice(0, 500)}`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
for (const app of pageApps) {
|
|
456
|
+
if (app.aud) {
|
|
457
|
+
apps.push({
|
|
458
|
+
id: app.id,
|
|
459
|
+
name: app.name,
|
|
460
|
+
domain: app.domain,
|
|
461
|
+
aud: app.aud,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// Full page heuristic: fewer results than the page size means the
|
|
466
|
+
// last page (the endpoint may not report pagination info).
|
|
467
|
+
if (pageApps.length < 100) break;
|
|
468
|
+
}
|
|
469
|
+
return apps;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async listPolicies(appId: string): Promise<ExistingAccessPolicy[]> {
|
|
473
|
+
const accountId = await this.resolveAccountId();
|
|
474
|
+
const policies = await this.request<
|
|
475
|
+
Array<{
|
|
476
|
+
id: string;
|
|
477
|
+
name: string;
|
|
478
|
+
decision: string;
|
|
479
|
+
include: unknown[];
|
|
480
|
+
reusable?: boolean;
|
|
481
|
+
}>
|
|
482
|
+
>("GET", `/accounts/${accountId}/access/apps/${appId}/policies`);
|
|
483
|
+
// The app-scoped response may not mark a reusable policy, so fall back
|
|
484
|
+
// to the account-level registry before deciding how to mutate it.
|
|
485
|
+
const reusableIds = await this.reusablePolicyIds();
|
|
486
|
+
return policies.map((policy) => ({
|
|
487
|
+
id: policy.id,
|
|
488
|
+
name: policy.name,
|
|
489
|
+
decision: policy.decision,
|
|
490
|
+
include: policy.include,
|
|
491
|
+
reusable: policy.reusable === true || reusableIds.has(policy.id),
|
|
492
|
+
}));
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private reusablePolicyIdsPromise: Promise<Set<string>> | undefined;
|
|
496
|
+
|
|
497
|
+
/** Ids of the account-level (reusable) policies, fetched once per client. */
|
|
498
|
+
private reusablePolicyIds(): Promise<Set<string>> {
|
|
499
|
+
this.reusablePolicyIdsPromise ??= this.loadReusablePolicyIds();
|
|
500
|
+
return this.reusablePolicyIdsPromise;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private async loadReusablePolicyIds(): Promise<Set<string>> {
|
|
504
|
+
const accountId = await this.resolveAccountId();
|
|
505
|
+
const ids = new Set<string>();
|
|
506
|
+
for (let page = 1; ; page += 1) {
|
|
507
|
+
const policies = await this.request<Array<{ id: string }>>(
|
|
508
|
+
"GET",
|
|
509
|
+
`/accounts/${accountId}/access/policies?per_page=100&page=${page}`,
|
|
510
|
+
);
|
|
511
|
+
if (!Array.isArray(policies)) {
|
|
512
|
+
throw new Error(
|
|
513
|
+
`Unexpected Access reusable policies response shape: ` +
|
|
514
|
+
`${JSON.stringify(policies).slice(0, 500)}`,
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
for (const policy of policies) ids.add(policy.id);
|
|
518
|
+
if (policies.length < 100) break;
|
|
519
|
+
}
|
|
520
|
+
return ids;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async listServiceTokens(): Promise<ServiceToken[]> {
|
|
524
|
+
const accountId = await this.resolveAccountId();
|
|
525
|
+
const tokens = await this.request<
|
|
526
|
+
Array<{ id: string; name: string; client_id: string; expires_at?: string | null }>
|
|
527
|
+
>("GET", `/accounts/${accountId}/access/service_tokens?per_page=100`);
|
|
528
|
+
if (!Array.isArray(tokens)) {
|
|
529
|
+
throw new Error(`Unexpected Access service tokens response shape: ${JSON.stringify(tokens).slice(0, 500)}`);
|
|
530
|
+
}
|
|
531
|
+
return tokens.map((token) => ({
|
|
532
|
+
id: token.id,
|
|
533
|
+
name: token.name,
|
|
534
|
+
clientId: token.client_id,
|
|
535
|
+
expiresAt: token.expires_at ?? null,
|
|
536
|
+
}));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** The secret is returned once, here, and never again. */
|
|
540
|
+
async createServiceToken(name: string): Promise<ServiceToken & { clientSecret: string }> {
|
|
541
|
+
const accountId = await this.resolveAccountId();
|
|
542
|
+
const created = await this.request<{
|
|
543
|
+
id: string;
|
|
544
|
+
name: string;
|
|
545
|
+
client_id: string;
|
|
546
|
+
client_secret: string;
|
|
547
|
+
expires_at?: string | null;
|
|
548
|
+
}>("POST", `/accounts/${accountId}/access/service_tokens`, { name });
|
|
549
|
+
return {
|
|
550
|
+
id: created.id,
|
|
551
|
+
name: created.name,
|
|
552
|
+
clientId: created.client_id,
|
|
553
|
+
clientSecret: created.client_secret,
|
|
554
|
+
expiresAt: created.expires_at ?? null,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async createServiceAuthPolicy(appId: string, tokenIds: readonly string[]): Promise<void> {
|
|
559
|
+
const accountId = await this.resolveAccountId();
|
|
560
|
+
await this.request("POST", `/accounts/${accountId}/access/apps/${appId}/policies`, this.serviceAuthPolicy(tokenIds));
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async updateServiceAuthPolicy(
|
|
564
|
+
appId: string,
|
|
565
|
+
policyId: string,
|
|
566
|
+
tokenIds: readonly string[],
|
|
567
|
+
reusable = false,
|
|
568
|
+
): Promise<void> {
|
|
569
|
+
const accountId = await this.resolveAccountId();
|
|
570
|
+
await this.request(
|
|
571
|
+
"PUT",
|
|
572
|
+
this.policyPath(accountId, appId, policyId, reusable),
|
|
573
|
+
this.serviceAuthPolicy(tokenIds),
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Reusable policies live at the account scope: the app-scoped endpoints
|
|
579
|
+
* reject updates and deletes of them (error 12130).
|
|
580
|
+
*/
|
|
581
|
+
private policyPath(
|
|
582
|
+
accountId: string,
|
|
583
|
+
appId: string,
|
|
584
|
+
policyId: string,
|
|
585
|
+
reusable: boolean,
|
|
586
|
+
): string {
|
|
587
|
+
return reusable
|
|
588
|
+
? `/accounts/${accountId}/access/policies/${policyId}`
|
|
589
|
+
: `/accounts/${accountId}/access/apps/${appId}/policies/${policyId}`;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
private serviceAuthPolicy(tokenIds: readonly string[]) {
|
|
593
|
+
return {
|
|
594
|
+
name: INTEGRATIONS_POLICY_NAME,
|
|
595
|
+
decision: "non_identity",
|
|
596
|
+
include: tokenIds.map((tokenId) => ({ service_token: { token_id: tokenId } })),
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async createApp(
|
|
601
|
+
name: string,
|
|
602
|
+
domain: string,
|
|
603
|
+
sessionDuration?: string,
|
|
604
|
+
extra: Record<string, unknown> = {},
|
|
605
|
+
): Promise<ExistingAccessApp> {
|
|
606
|
+
const accountId = await this.resolveAccountId();
|
|
607
|
+
const created = await this.request<{
|
|
608
|
+
id: string;
|
|
609
|
+
name: string;
|
|
610
|
+
domain: string;
|
|
611
|
+
aud: string;
|
|
612
|
+
}>("POST", `/accounts/${accountId}/access/apps`, {
|
|
613
|
+
name,
|
|
614
|
+
domain,
|
|
615
|
+
type: "self_hosted",
|
|
616
|
+
...(sessionDuration ? { session_duration: sessionDuration } : {}),
|
|
617
|
+
...extra,
|
|
618
|
+
});
|
|
619
|
+
return {
|
|
620
|
+
id: created.id,
|
|
621
|
+
name: created.name,
|
|
622
|
+
domain: created.domain,
|
|
623
|
+
aud: created.aud,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async createAllowPolicy(
|
|
628
|
+
appId: string,
|
|
629
|
+
emails: readonly string[],
|
|
630
|
+
): Promise<void> {
|
|
631
|
+
const accountId = await this.resolveAccountId();
|
|
632
|
+
await this.request(
|
|
633
|
+
"POST",
|
|
634
|
+
`/accounts/${accountId}/access/apps/${appId}/policies`,
|
|
635
|
+
{
|
|
636
|
+
name: ALLOW_POLICY_NAME,
|
|
637
|
+
decision: "allow",
|
|
638
|
+
include: emails.map((email) => ({ email: { email } })),
|
|
639
|
+
},
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Delete an application and the policies attached to it. */
|
|
644
|
+
async deleteApp(appId: string): Promise<void> {
|
|
645
|
+
const accountId = await this.resolveAccountId();
|
|
646
|
+
await this.request("DELETE", `/accounts/${accountId}/access/apps/${appId}`);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** Rewrite one policy's email list in place, dropping removed emails. */
|
|
650
|
+
async updateAllowPolicy(
|
|
651
|
+
appId: string,
|
|
652
|
+
policyId: string,
|
|
653
|
+
name: string,
|
|
654
|
+
emails: readonly string[],
|
|
655
|
+
reusable = false,
|
|
656
|
+
): Promise<void> {
|
|
657
|
+
const accountId = await this.resolveAccountId();
|
|
658
|
+
await this.request(
|
|
659
|
+
"PUT",
|
|
660
|
+
this.policyPath(accountId, appId, policyId, reusable),
|
|
661
|
+
{
|
|
662
|
+
name,
|
|
663
|
+
decision: "allow",
|
|
664
|
+
include: emails.map((email) => ({ email: { email } })),
|
|
665
|
+
},
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Remove a redundant Allow policy left over from earlier provisioning. */
|
|
670
|
+
async deletePolicy(
|
|
671
|
+
appId: string,
|
|
672
|
+
policyId: string,
|
|
673
|
+
reusable = false,
|
|
674
|
+
): Promise<void> {
|
|
675
|
+
const accountId = await this.resolveAccountId();
|
|
676
|
+
await this.request(
|
|
677
|
+
"DELETE",
|
|
678
|
+
this.policyPath(accountId, appId, policyId, reusable),
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Performs one API call and normalizes the two known Cloudflare response
|
|
684
|
+
* shapes: the legacy envelope ({ success, result, errors }) and bare
|
|
685
|
+
* payloads (arrays for list endpoints, objects for single resources).
|
|
686
|
+
*/
|
|
687
|
+
private async request<T>(
|
|
688
|
+
method: "GET" | "POST" | "PUT" | "DELETE",
|
|
689
|
+
path: string,
|
|
690
|
+
body?: unknown,
|
|
691
|
+
): Promise<T> {
|
|
692
|
+
let response: Response;
|
|
693
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
694
|
+
response = await this.fetchRequest(`${this.base}${path}`, {
|
|
695
|
+
method,
|
|
696
|
+
headers: {
|
|
697
|
+
Authorization: `Bearer ${this.token}`,
|
|
698
|
+
"Content-Type": "application/json",
|
|
699
|
+
},
|
|
700
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
701
|
+
});
|
|
702
|
+
if (response.status !== 429 && response.status < 500) break;
|
|
703
|
+
if (attempt >= 2) break;
|
|
704
|
+
const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
|
|
705
|
+
console.warn(
|
|
706
|
+
`Cloudflare API ${method} ${path} returned ${response.status}; ` +
|
|
707
|
+
`retrying in ${retryAfter}ms`,
|
|
708
|
+
);
|
|
709
|
+
await new Promise((resolve) => setTimeout(resolve, retryAfter));
|
|
710
|
+
}
|
|
711
|
+
const payload = (await response.json().catch(() => null)) as unknown;
|
|
712
|
+
if (!response.ok) {
|
|
713
|
+
throw this.apiError(method, path, response.status, payload);
|
|
714
|
+
}
|
|
715
|
+
if (
|
|
716
|
+
payload &&
|
|
717
|
+
typeof payload === "object" &&
|
|
718
|
+
!Array.isArray(payload) &&
|
|
719
|
+
"success" in payload &&
|
|
720
|
+
"result" in payload
|
|
721
|
+
) {
|
|
722
|
+
const envelope = payload as {
|
|
723
|
+
success: boolean;
|
|
724
|
+
result: T;
|
|
725
|
+
errors?: Array<{ code: number; message: string }>;
|
|
726
|
+
};
|
|
727
|
+
if (!envelope.success) {
|
|
728
|
+
throw this.apiError(method, path, response.status, payload);
|
|
729
|
+
}
|
|
730
|
+
return envelope.result;
|
|
731
|
+
}
|
|
732
|
+
return payload as T;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
private apiError(
|
|
736
|
+
method: string,
|
|
737
|
+
path: string,
|
|
738
|
+
status: number,
|
|
739
|
+
payload: unknown,
|
|
740
|
+
): Error {
|
|
741
|
+
let detail = "unknown error";
|
|
742
|
+
const errorCodes: number[] = [];
|
|
743
|
+
if (payload && typeof payload === "object") {
|
|
744
|
+
const record = payload as Record<string, unknown>;
|
|
745
|
+
if (Array.isArray(record.errors)) {
|
|
746
|
+
const errors = record.errors as Array<{
|
|
747
|
+
code: number;
|
|
748
|
+
message: string;
|
|
749
|
+
}>;
|
|
750
|
+
detail = errors
|
|
751
|
+
.map((error) => `${error.code}: ${error.message}`)
|
|
752
|
+
.join("; ");
|
|
753
|
+
errorCodes.push(...errors.map((error) => error.code));
|
|
754
|
+
} else if (record.error) {
|
|
755
|
+
detail = String(record.error);
|
|
756
|
+
} else {
|
|
757
|
+
detail = JSON.stringify(payload).slice(0, 500);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
// Token problems arrive as 401/403 or, in practice, as 400 carrying
|
|
761
|
+
// Cloudflare auth error codes (9106 "Authentication failed", 9109
|
|
762
|
+
// "Unauthorized to access requested resource", 10000 "Authentication
|
|
763
|
+
// error"). Say what the token must allow so the fix is obvious.
|
|
764
|
+
const authFailure =
|
|
765
|
+
status === 401 ||
|
|
766
|
+
status === 403 ||
|
|
767
|
+
errorCodes.some((code) =>
|
|
768
|
+
code === 9106 || code === 9109 || code === 10000
|
|
769
|
+
);
|
|
770
|
+
const tokenHint = authFailure
|
|
771
|
+
? `\nThe CLOUDFLARE_API_TOKEN needs the ${path.endsWith("/access/organizations")
|
|
772
|
+
? '"Access: Organizations, Identity Providers, and Groups -> Edit" permission'
|
|
773
|
+
: ACCESS_TOKEN_REQUIREMENT}.`
|
|
774
|
+
: "";
|
|
775
|
+
return new Error(
|
|
776
|
+
`Cloudflare API ${method} ${path} failed (${status}): ${detail}${tokenHint}`,
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function parseRetryAfter(value: string | null): number {
|
|
783
|
+
if (!value) return 500;
|
|
784
|
+
const seconds = Number(value);
|
|
785
|
+
if (Number.isFinite(seconds)) return Math.min(30_000, Math.max(100, seconds * 1000));
|
|
786
|
+
const timestamp = Date.parse(value);
|
|
787
|
+
if (!Number.isFinite(timestamp)) return 500;
|
|
788
|
+
return Math.min(30_000, Math.max(100, timestamp - Date.now()));
|
|
789
|
+
}
|