@brutal-ai/server 0.1.0 → 0.2.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/cli.js +123 -2
- package/config.d.ts +2 -0
- package/config.js +7 -1
- package/package.json +1 -1
- package/worker/index.js +343 -231
- package/worker/index.js.map +1 -1
package/cli.js
CHANGED
|
@@ -48,14 +48,91 @@ function loadBrutalConfig(dir, { allowDefaults = false } = {}) {
|
|
|
48
48
|
return config;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// worker/cli/access.ts
|
|
52
|
+
var same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
53
|
+
async function ensureAccess(cf, want, dryRun = false) {
|
|
54
|
+
const emails = [...new Set(want.emails.map((e) => e.trim().toLowerCase()).filter(Boolean))];
|
|
55
|
+
if (emails.length === 0 || emails.some((e) => !e.includes("@"))) {
|
|
56
|
+
throw new Error("Access needs at least one email address to let in.");
|
|
57
|
+
}
|
|
58
|
+
const changes = [];
|
|
59
|
+
const org = await cf(["organization", "get"]);
|
|
60
|
+
const team = org?.auth_domain ?? "";
|
|
61
|
+
if (!team) {
|
|
62
|
+
throw new Error("This Cloudflare account has no Zero Trust organization yet. Open Zero Trust in the " + "Cloudflare dashboard once to create one (the Free plan is enough), then run this again.");
|
|
63
|
+
}
|
|
64
|
+
const idps = await cf(["identity-providers", "list"]) ?? [];
|
|
65
|
+
let otp = idps.find((i) => i.type === "onetimepin");
|
|
66
|
+
if (!otp) {
|
|
67
|
+
changes.push("create the one-time PIN login");
|
|
68
|
+
otp = dryRun ? { id: "(new)", type: "onetimepin" } : await cf(["identity-providers", "create"], {
|
|
69
|
+
name: "One-time PIN",
|
|
70
|
+
type: "onetimepin",
|
|
71
|
+
config: {}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const policyName = `Brutal ${want.worker}: owners`;
|
|
75
|
+
const policyBody = {
|
|
76
|
+
name: policyName,
|
|
77
|
+
decision: "allow",
|
|
78
|
+
include: emails.map((email) => ({ email: { email } })),
|
|
79
|
+
exclude: [],
|
|
80
|
+
require: []
|
|
81
|
+
};
|
|
82
|
+
const policies = await cf(["access", "policies", "list"]) ?? [];
|
|
83
|
+
let policy = policies.find((p) => p.name === policyName);
|
|
84
|
+
if (!policy) {
|
|
85
|
+
changes.push(`create the policy "${policyName}" (allow ${emails.join(", ")})`);
|
|
86
|
+
policy = dryRun ? { id: "(new)", ...policyBody } : await cf(["access", "policies", "create"], policyBody);
|
|
87
|
+
} else if (policy.decision !== "allow" || !same(policy.include, policyBody.include)) {
|
|
88
|
+
changes.push(`correct the policy "${policyName}" (allow ${emails.join(", ")} only)`);
|
|
89
|
+
if (!dryRun) {
|
|
90
|
+
policy = await cf(["access", "policies", "update", policy.id], policyBody);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const domain = `${want.host}/dashboard`;
|
|
94
|
+
const title = `Brutal (${want.host})`;
|
|
95
|
+
const appBody = {
|
|
96
|
+
name: title,
|
|
97
|
+
domain,
|
|
98
|
+
type: "self_hosted",
|
|
99
|
+
session_duration: want.sessionDuration,
|
|
100
|
+
allowed_idps: [otp.id],
|
|
101
|
+
auto_redirect_to_identity: true,
|
|
102
|
+
app_launcher_visible: false,
|
|
103
|
+
policies: [{ id: policy.id, precedence: 1 }]
|
|
104
|
+
};
|
|
105
|
+
const apps = await cf(["access", "applications", "list"]) ?? [];
|
|
106
|
+
let app = apps.find((a) => a.domain === domain);
|
|
107
|
+
if (!app) {
|
|
108
|
+
changes.push(`create the application for ${domain} (${want.sessionDuration} sessions)`);
|
|
109
|
+
if (!dryRun)
|
|
110
|
+
app = await cf(["access", "applications", "create"], appBody);
|
|
111
|
+
} else if (app.name !== title || app.type !== "self_hosted" || app.session_duration !== want.sessionDuration || !same(app.allowed_idps, [otp.id]) || app.auto_redirect_to_identity !== true || !same((app.policies ?? []).map((p) => p.id), [policy.id])) {
|
|
112
|
+
changes.push(`correct the application for ${domain}`);
|
|
113
|
+
if (!dryRun)
|
|
114
|
+
app = await cf(["access", "applications", "update", app.id], appBody);
|
|
115
|
+
}
|
|
116
|
+
return { team, aud: app?.aud ?? "", changes };
|
|
117
|
+
}
|
|
118
|
+
function parseCfJson(stdout) {
|
|
119
|
+
const lines = stdout.split(`
|
|
120
|
+
`);
|
|
121
|
+
const start = lines.findIndex((l) => l.startsWith("[") || l.startsWith("{"));
|
|
122
|
+
if (start === -1)
|
|
123
|
+
return null;
|
|
124
|
+
return JSON.parse(lines.slice(start).join(`
|
|
125
|
+
`));
|
|
126
|
+
}
|
|
127
|
+
|
|
51
128
|
// worker/cli/brutal-server.ts
|
|
52
129
|
var root = process.cwd();
|
|
53
130
|
var [command, ...rest] = process.argv.slice(2);
|
|
54
131
|
process.env.CF_SEND_TELEMETRY ??= "false";
|
|
55
|
-
var commands = { setup, deploy, secrets, dev };
|
|
132
|
+
var commands = { setup, deploy, secrets, dev, access };
|
|
56
133
|
var run = command ? commands[command] : undefined;
|
|
57
134
|
if (!run) {
|
|
58
|
-
console.log("Usage: brutal-server <setup|deploy|secrets|dev>");
|
|
135
|
+
console.log("Usage: brutal-server <setup|deploy|secrets|dev|access>");
|
|
59
136
|
process.exit(command ? 1 : 0);
|
|
60
137
|
}
|
|
61
138
|
await run();
|
|
@@ -125,6 +202,50 @@ Setup code: ${setupCode}`);
|
|
|
125
202
|
console.log(`Open /setup on your instance and enter it to make your keys.
|
|
126
203
|
`);
|
|
127
204
|
}
|
|
205
|
+
async function access() {
|
|
206
|
+
const dryRun = rest.includes("--dry-run");
|
|
207
|
+
await requireAuth();
|
|
208
|
+
const current = readConfigFile(root);
|
|
209
|
+
const config = loadBrutalConfig(root);
|
|
210
|
+
if (!config.domain) {
|
|
211
|
+
console.error("Access needs a custom domain: run `bun run setup` and give one, deploy, then run this.");
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
let emails = config.access?.emails ?? [];
|
|
215
|
+
if (emails.length === 0) {
|
|
216
|
+
const answer = prompt("Email(s) allowed to sign in to the dashboard, comma-separated:");
|
|
217
|
+
emails = (answer ?? "").split(",").map((e) => e.trim()).filter(Boolean);
|
|
218
|
+
}
|
|
219
|
+
const cf = async (args, body) => {
|
|
220
|
+
const extra = body === undefined ? [] : ["--body", JSON.stringify(body)];
|
|
221
|
+
const out = await $`bunx cf zero-trust ${args} ${extra}`.nothrow().quiet();
|
|
222
|
+
if (out.exitCode !== 0) {
|
|
223
|
+
throw new Error(`cf zero-trust ${args.join(" ")} failed: ${out.stderr.toString() || out.stdout.toString()}`);
|
|
224
|
+
}
|
|
225
|
+
return parseCfJson(out.stdout.toString());
|
|
226
|
+
};
|
|
227
|
+
console.log(`
|
|
228
|
+
Cloudflare Access for https://${config.domain}/dashboard
|
|
229
|
+
`);
|
|
230
|
+
const ensured = await ensureAccess(cf, { host: config.domain, worker: config.worker, emails, sessionDuration: "24h" }, dryRun);
|
|
231
|
+
for (const change of ensured.changes)
|
|
232
|
+
console.log(`${dryRun ? "Would" : "\u2713"} ${change}`);
|
|
233
|
+
if (ensured.changes.length === 0)
|
|
234
|
+
console.log("\u2713 Access is already as it should be");
|
|
235
|
+
if (dryRun)
|
|
236
|
+
return;
|
|
237
|
+
const next = {
|
|
238
|
+
...current ?? config,
|
|
239
|
+
worker: current?.worker ?? config.worker,
|
|
240
|
+
access: { emails, team: ensured.team, aud: ensured.aud }
|
|
241
|
+
};
|
|
242
|
+
writeFileSync(configPath(root), `${JSON.stringify(next, null, 2)}
|
|
243
|
+
`);
|
|
244
|
+
console.log(`\u2713 Wrote the team and application to ${basename(configPath(root))}`);
|
|
245
|
+
console.log(`
|
|
246
|
+
Next: bun run deploy. The dashboard then signs in with a code mailed to you.
|
|
247
|
+
`);
|
|
248
|
+
}
|
|
128
249
|
async function dev() {
|
|
129
250
|
process.exit(await interactive(["bunx", "cf", "dev", "--port", "8787", ...rest]));
|
|
130
251
|
}
|
package/config.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export type BrutalConfig = {
|
|
|
6
6
|
database?: string;
|
|
7
7
|
/** Custom domain on a zone in your Cloudflare account. Without one, workers.dev serves it. */
|
|
8
8
|
domain?: string | null;
|
|
9
|
+
/** Cloudflare Access in front of /dashboard; `brutal-server access` sets it up. */
|
|
10
|
+
access?: { emails: string[]; team?: string; aud?: string } | null;
|
|
9
11
|
};
|
|
10
12
|
|
|
11
13
|
/**
|
package/config.js
CHANGED
|
@@ -46,6 +46,7 @@ function defineBrutal({ dir, entrypoint }) {
|
|
|
46
46
|
allowDefaults: argv.includes("dev") || argv.includes("types")
|
|
47
47
|
});
|
|
48
48
|
const domain = brutal.domain || null;
|
|
49
|
+
const access = domain && brutal.access?.team && brutal.access.aud ? brutal.access : null;
|
|
49
50
|
return {
|
|
50
51
|
worker: {
|
|
51
52
|
name: brutal.worker,
|
|
@@ -57,7 +58,12 @@ function defineBrutal({ dir, entrypoint }) {
|
|
|
57
58
|
workersDev: !domain,
|
|
58
59
|
previewUrls: false,
|
|
59
60
|
env: {
|
|
60
|
-
DB: bindings.d1({ name: brutal.database ?? brutal.worker })
|
|
61
|
+
DB: bindings.d1({ name: brutal.database ?? brutal.worker }),
|
|
62
|
+
...access && {
|
|
63
|
+
ACCESS_TEAM: bindings.text(access.team ?? ""),
|
|
64
|
+
ACCESS_AUD: bindings.text(access.aud ?? ""),
|
|
65
|
+
ACCESS_EMAILS: bindings.text(access.emails.join(","))
|
|
66
|
+
}
|
|
61
67
|
}
|
|
62
68
|
}
|
|
63
69
|
};
|
package/package.json
CHANGED