@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 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
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0",
2
+ "version": "0.2.0",
3
3
  "license": "MIT",
4
4
  "name": "@brutal-ai/server",
5
5
  "description": "Brutal: feedback from your agents, deployed to your own Cloudflare account",