create-safest-tools 0.2.2 → 0.3.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/README.md CHANGED
@@ -6,7 +6,7 @@ Safest Resolve accepts explicit reports from an embedded widget, a public page,
6
6
 
7
7
  ## Quick start
8
8
 
9
- You need Node.js 20.12+, a Cloudflare account with Workers, Dynamic Workflows, Worker Loaders, D1, R2, Queues, Durable Objects, Workers AI, and Access available, plus an HTTPS hostname for the reports service.
9
+ You need Node.js 20.12+, a Cloudflare account with Workers, Dynamic Workflows, Worker Loaders, D1, R2, Queues, Durable Objects, and Workers AI available, plus an HTTPS hostname for the reports service.
10
10
 
11
11
  ```bash
12
12
  npx create-safest-tools safest-resolve \
@@ -14,17 +14,12 @@ npx create-safest-tools safest-resolve \
14
14
  --name acme-resolve \
15
15
  --public-url https://reports.example.com \
16
16
  --origin https://app.example.com \
17
- --admin-email infrastructure@example.com \
18
- --access-aud <cloudflare-access-application-aud> \
17
+ --owner-email infrastructure@example.com \
19
18
  --email-from reports@example.com
20
19
  ```
21
20
 
22
21
  The `--email-from` address must belong to a domain onboarded under Cloudflare Email Service → Email Sending. Domain onboarding permits delivery to arbitrary invited users; invitations, email verification, and forgot-password delivery use this address.
23
22
 
24
- ### Where to find the Cloudflare Access AUD
25
-
26
- In the Cloudflare dashboard, go to **Zero Trust → Access controls → Applications** and create a **Self-hosted and private** application named **Safest Resolve infrastructure**. Add the reports hostname with **Path** `v1/infrastructure/*` only; do not protect the entire hostname. Add or create a policy named **Safest infrastructure owners** with **Action: Allow** and an **Include → Emails** rule containing only the infrastructure-owner email supplied to `--admin-email`. Do not use **Everyone**. Create the application, select **Configure → Additional settings**, and copy the 64-character **Application Audience (AUD) Tag** into `--access-aud`. The tag stays the same unless the Access application is deleted or recreated. See Cloudflare's [application-path](https://developers.cloudflare.com/cloudflare-one/access-controls/policies/app-paths/) and [Get your AUD tag](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/#get-your-aud-tag) instructions.
27
-
28
23
  The command creates a local project and prints a read-only infrastructure plan. It does not change Cloudflare unless `--deploy` is supplied or the generated project’s `npm run setup` command is run and explicitly confirmed.
29
24
 
30
25
  The generated installation owns:
@@ -35,8 +30,7 @@ The generated installation owns:
35
30
  - report jobs, delivery jobs, and operations dead-letter Queues;
36
31
  - one Dynamic Workflow and a Worker Loader for compiled durable workflow execution;
37
32
  - one SQLite-backed Durable Object class for analyst presence;
38
- - a Workers AI binding and customer-supplied secrets;
39
- - a customer-created Cloudflare Access application for infrastructure-owner bootstrap only.
33
+ - a Workers AI binding and customer-supplied secrets.
40
34
 
41
35
  The reports service stores customer application references by default. Webhook enrichments receive only their explicitly published input allowlist and return schema-validated, expiring facts with signed provenance.
42
36
 
@@ -52,7 +46,9 @@ npm run setup
52
46
 
53
47
  `setup:plan` lists exact resource names and exits without modifying Cloudflare. `setup` opens Wrangler's Cloudflare login when needed, lets you choose the owning account, and verifies Workers Paid from the account's Workers usage model. Standard accounts need no separate billing token; only legacy or ambiguous account models use the temporary Billing Read fallback. It then guides Google, GitHub, and Cloudflare OAuth configuration with exact callback URLs and masked secret input. Nothing is provisioned until the exact `DEPLOY <installation-id>` confirmation.
54
48
 
55
- Create a Cloudflare Access self-hosted application for `<reports-host>/v1/infrastructure/*` first and copy its 64-character audience into `reports.config.json`. The allowlisted infrastructure engineer uses it to bootstrap the Safest owner account. The owner then invites administrators, and administrators invite analysts. Those invited users sign in with Safest accounts and do not need Cloudflare accounts.
49
+ After deployment, setup prints a 256-bit, single-use owner link that expires after 15 minutes. The raw token stays in the URL fragment, D1 stores only its digest, and the installer never writes it to disk. Open it to create the Safest owner account. If it expires, run `npm run owner:setup`. For later break-glass recovery, authenticate Wrangler and run `npm run owner:recover`.
50
+
51
+ The owner then invites administrators, and administrators invite analysts. All users sign in with Safest accounts and do not need Cloudflare accounts.
56
52
 
57
53
  Invited users may join with a password or any configured OAuth provider, then choose a natural display name and optional JPEG, PNG, or WebP profile picture up to 2 MB. Better Auth stores credentials, OAuth accounts, sessions, verification state, and one-time password-reset tokens in D1. Cloudflare Email Service sends invitations, verification links, and 30-minute password-reset links.
58
54
 
@@ -70,6 +66,6 @@ npm run uninstall:plan
70
66
 
71
67
  Backups contain the D1 export and every object in the four private R2 buckets, with stable inventories and SHA-256 verification. They intentionally exclude pending Queue messages, live Workflow engine state, Worker secrets, external provider state, and ephemeral presence. Restore verification is local; restore planning requires a separate target installation and never mutates Cloudflare. Upgrade runs a verified backup before forward-only migrations and deployment.
72
68
 
73
- The uninstall command is intentionally plan-only: it identifies the exact reporting resources but does not delete them. D1, all private R2 buckets, Access, and secrets are preserved by default.
69
+ The uninstall command is intentionally plan-only: it identifies the exact reporting resources but does not delete them. D1, all private R2 buckets, and secrets are preserved by default.
74
70
 
75
71
  Run `npx create-safest-tools --help` for all options.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-safest-tools",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Create customer-owned abuse-reporting infrastructure on Cloudflare",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/src/cli.mjs CHANGED
@@ -9,23 +9,6 @@ import { scaffoldProject, validateGeneratedConfiguration } from "./scaffold.mjs"
9
9
  const packageRoot = fileURLToPath(new URL("..", import.meta.url));
10
10
  const packageVersion = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8")).version;
11
11
  const defaultTemplateDirectory = resolve(packageRoot, "template");
12
- const accessAudienceDocumentation = "https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/#get-your-aud-tag";
13
-
14
- export function accessAudienceGuidance(publicBaseUrl = "", adminEmails = []) {
15
- let reportsHost = "<reports-host>";
16
- try { reportsHost = new URL(publicBaseUrl).host || reportsHost; } catch {}
17
- const ownerEmail = adminEmails[0] || "<infrastructure-owner-email>";
18
- return `Before entering the Cloudflare Access application AUD:
19
- 1. In Cloudflare, go to Zero Trust > Access controls > Applications.
20
- 2. Create a Self-hosted and private application named Safest Resolve infrastructure.
21
- 3. Add public hostname ${reportsHost} with Path v1/infrastructure/* only.
22
- 4. Add a policy named Safest infrastructure owners: Action Allow; Include Emails; Value ${ownerEmail}.
23
- Do not select Everyone and do not protect the entire reporting hostname.
24
- 5. Create the application, then select Configure > Additional settings.
25
- 6. Copy the 64-character Application Audience (AUD) Tag.
26
- The tag stays the same unless you delete or recreate the Access application.
27
- Cloudflare guide: ${accessAudienceDocumentation}`;
28
- }
29
12
 
30
13
  function usage() {
31
14
  return `Create customer-owned abuse-reporting infrastructure on Cloudflare
@@ -34,15 +17,15 @@ Usage:
34
17
  npx create-safest-tools [directory]
35
18
  npx create-safest-tools safest-resolve --yes --name acme-resolve \\
36
19
  --public-url https://reports.example.com --origin https://app.example.com \\
37
- --admin-email infrastructure@example.com --access-aud <cloudflare-access-aud> \\
20
+ --owner-email infrastructure@example.com \\
38
21
  --email-from reports@example.com
39
22
 
40
23
  Options:
41
24
  --name id Stable installation/resource prefix
42
25
  --public-url origin Public report, widget, and console origin
43
26
  --origin origin Allowed embedding/application origin; repeat as needed
44
- --admin-email email Infrastructure owner allowed through Access; repeat as needed
45
- --access-aud value Audience of the owner-bootstrap Cloudflare Access app
27
+ --owner-email email Initial infrastructure owner bound to one-time setup
28
+ --admin-email email Deprecated alias for --owner-email
46
29
  --email-from address Address on an onboarded Email Sending domain (required)
47
30
  --yes Do not prompt for omitted optional values
48
31
  --skip-install Create files without running npm install or setup:plan
@@ -51,8 +34,6 @@ Options:
51
34
  --help Show this help
52
35
  --version Show the package version
53
36
 
54
- ${accessAudienceGuidance()}
55
-
56
37
  This product does not proxy or inspect application requests. Applications submit
57
38
  reports explicitly through the widget, public form, or server API.
58
39
  `;
@@ -67,7 +48,7 @@ function valueAfter(argv, index, option) {
67
48
  export function parseArguments(argv) {
68
49
  const result = {
69
50
  directory: null, installationName: null, publicBaseUrl: null, allowedOrigins: [],
70
- adminEmails: [], accessAudience: null, emailFromAddress: null, yes: false, skipInstall: false,
51
+ ownerEmail: null, emailFromAddress: null, yes: false, skipInstall: false,
71
52
  deploy: false, dryRun: false, help: false, version: false,
72
53
  };
73
54
  for (let index = 0; index < argv.length; index += 1) {
@@ -81,14 +62,16 @@ export function parseArguments(argv) {
81
62
  else if (argument === "--dry-run") result.dryRun = true;
82
63
  else if (argument === "--help" || argument === "-h") result.help = true;
83
64
  else if (argument === "--version" || argument === "-v") result.version = true;
84
- else if (["--name", "--public-url", "--origin", "--admin-email", "--access-aud", "--email-from"].includes(argument)) {
65
+ else if (["--name", "--public-url", "--origin", "--owner-email", "--admin-email", "--email-from"].includes(argument)) {
85
66
  const value = valueAfter(argv, index, argument);
86
67
  index += 1;
87
68
  if (argument === "--name") result.installationName = value;
88
69
  else if (argument === "--public-url") result.publicBaseUrl = value;
89
70
  else if (argument === "--origin") result.allowedOrigins.push(value);
90
- else if (argument === "--admin-email") result.adminEmails.push(...value.split(","));
91
- else if (argument === "--access-aud") result.accessAudience = value;
71
+ else if (argument === "--owner-email" || argument === "--admin-email") {
72
+ if (result.ownerEmail && result.ownerEmail !== value) throw new Error("provide only one infrastructure owner email");
73
+ result.ownerEmail = value;
74
+ }
92
75
  else result.emailFromAddress = value;
93
76
  } else throw new Error(`unknown option: ${argument}`);
94
77
  }
@@ -109,15 +92,11 @@ async function ask(input, prompt, fallback = "") {
109
92
  return answer || fallback;
110
93
  }
111
94
 
112
- async function completeInteractive(options, input, output) {
95
+ async function completeInteractive(options, input) {
113
96
  options.installationName ||= await ask(input, "Installation name", "safest-resolve");
114
97
  options.publicBaseUrl ||= await ask(input, "Public reports origin", "https://reports.example.com");
115
98
  if (!options.allowedOrigins.length) options.allowedOrigins.push(await ask(input, "Application origin allowed to embed the report form", "https://app.example.com"));
116
- if (!options.adminEmails.length) options.adminEmails.push(await ask(input, "Infrastructure owner email"));
117
- if (!options.accessAudience) {
118
- output.log(`\n${accessAudienceGuidance(options.publicBaseUrl, options.adminEmails)}\n`);
119
- options.accessAudience = await ask(input, "Cloudflare Access application AUD (press Enter to use a deployment-blocking placeholder)", "replace-with-the-cloudflare-access-application-aud");
120
- }
99
+ options.ownerEmail ||= await ask(input, "Infrastructure owner email");
121
100
  options.emailFromAddress ||= await ask(input, "Sender address on a Cloudflare Email Sending domain");
122
101
  return options;
123
102
  }
@@ -130,7 +109,7 @@ export async function runCli(argv, dependencies = {}) {
130
109
  const interactive = dependencies.interactive ?? (process.stdin.isTTY && !options.yes);
131
110
  if (interactive) {
132
111
  const input = createInterface({ input: process.stdin, output: process.stdout });
133
- try { await completeInteractive(options, input, output); } finally { input.close(); }
112
+ try { await completeInteractive(options, input); } finally { input.close(); }
134
113
  }
135
114
  const configuration = buildConfiguration(options);
136
115
  const templateDirectory = dependencies.templateDirectory ?? defaultTemplateDirectory;
package/src/config.mjs CHANGED
@@ -17,12 +17,6 @@ function httpsOrigin(value, field) {
17
17
  return url.origin;
18
18
  }
19
19
 
20
- function emails(values) {
21
- const result = [...new Set((values ?? []).map((value) => String(value).trim().toLowerCase()).filter(Boolean))];
22
- if (result.some((value) => !EMAIL.test(value))) throw new Error("every --admin-email must be a valid email address");
23
- return result;
24
- }
25
-
26
20
  function optionalEmail(value, field) {
27
21
  const result = String(value ?? "").trim().toLowerCase();
28
22
  if (result && !EMAIL.test(result)) throw new Error(`${field} must be a valid email address`);
@@ -39,9 +33,8 @@ export function buildConfiguration(options) {
39
33
  const publicBaseUrl = httpsOrigin(options.publicBaseUrl, "public URL");
40
34
  const allowedOrigins = [...new Set((options.allowedOrigins ?? []).map((value, index) => httpsOrigin(value, `allowed origin ${index + 1}`)))];
41
35
  if (!allowedOrigins.length) throw new Error("at least one --origin is required for widget and API embedding");
42
- const adminEmails = emails(options.adminEmails);
43
- if (!adminEmails.length) throw new Error("at least one --admin-email is required");
44
- const accessAudience = String(options.accessAudience ?? "replace-with-the-cloudflare-access-application-aud").trim();
36
+ const ownerEmail = optionalEmail(options.ownerEmail ?? options.adminEmails?.[0], "owner email");
37
+ if (!ownerEmail) throw new Error("--owner-email is required");
45
38
  const emailFromAddress = optionalEmail(options.emailFromAddress, "email sender");
46
39
  if (!emailFromAddress) throw new Error("--email-from is required and its domain must be onboarded to Cloudflare Email Sending");
47
40
  const resources = {
@@ -62,15 +55,12 @@ export function buildConfiguration(options) {
62
55
  }
63
56
  return {
64
57
  $schema: "./reports.schema.json",
65
- schemaVersion: 1,
58
+ schemaVersion: 2,
66
59
  installationId,
67
60
  resources,
68
61
  publicBaseUrl,
69
62
  allowedOrigins,
70
- access: {
71
- audience: accessAudience,
72
- ownerEmails: adminEmails,
73
- },
63
+ owner: { email: ownerEmail },
74
64
  email: { enabled: true, fromAddress: emailFromAddress },
75
65
  auth: { password: true, providers: [] },
76
66
  retention: { reportsDays: 365, messagesDays: 180, appealWindowDays: 30 },
@@ -131,8 +121,6 @@ export function buildWranglerConfiguration(config) {
131
121
  PUBLIC_INTEGRATION_ID: "default",
132
122
  PUBLIC_REPORT_LIMIT_PER_10_MINUTES: "5",
133
123
  PUBLIC_REPORT_SURGE_PER_5_MINUTES: "250",
134
- ACCESS_AUD: config.access.audience,
135
- INFRASTRUCTURE_OWNER_EMAILS: config.access.ownerEmails.join(","),
136
124
  TURNSTILE_SITE_KEY: "",
137
125
  ACTION_WEBHOOK_URL: "",
138
126
  NOTIFICATION_WEBHOOK_URL: "",
@@ -4,18 +4,19 @@ Safest Resolve handles abuse, safety, fraud, and policy reports in the Cloudflar
4
4
 
5
5
  The service does not proxy or inspect normal application traffic. The customer application remains responsible for the content references it provides and for carrying out any approved action.
6
6
 
7
- This project deploys one Worker, one D1 database, four private R2 buckets, a Dynamic Workflow, report and delivery Queues, an operations dead-letter Queue, a presence Durable Object, static assets, a Worker Loader, and a Workers AI binding. The Cloudflare account owner creates the Access application and owns every secret, webhook, log, and retained row.
7
+ This project deploys one Worker, one D1 database, four private R2 buckets, a Dynamic Workflow, report and delivery Queues, an operations dead-letter Queue, a presence Durable Object, static assets, a Worker Loader, and a Workers AI binding. The Cloudflare account owner owns every secret, webhook, log, and retained row.
8
8
 
9
9
  Before deployment:
10
10
 
11
11
  1. Review `reports.config.json` and `npm run setup:plan`.
12
- 2. In Cloudflare, go to **Zero Trust Access controls Applications** and create a **Self-hosted and private** application named **Safest Resolve infrastructure**. Add the reports hostname with **Path** `v1/infrastructure/*` only; do not protect the entire hostname. Add a reusable policy named **Safest infrastructure owners** with **Action: Allow** and an **Include Emails** rule containing only the email addresses in `access.ownerEmails`; do not use **Everyone**. Create the application, select **Configure → Additional settings**, and copy the 64-character **Application Audience (AUD) Tag** into `access.audience`. The tag stays stable unless the Access application is deleted or recreated. See Cloudflare's [application-path](https://developers.cloudflare.com/cloudflare-one/access-controls/policies/app-paths/), [policy](https://developers.cloudflare.com/cloudflare-one/access-controls/policies/policy-management/), and [Get your AUD tag](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/#get-your-aud-tag) instructions.
13
- 3. Under Cloudflare Email Service Email Sending, onboard the configured sender domain. Password verification, invitations, and password recovery to arbitrary recipients depend on domain onboarding.
14
- 4. Run `npm run setup`. The guided setup signs in through Wrangler and verifies Workers Paid from the account's Workers usage model before provisioning. Standard accounts need no separate billing token; only legacy or ambiguous models use the temporary Billing Read fallback. Setup then creates owner-only local secrets and walks through optional Google, GitHub, and Cloudflare OAuth credentials with exact callback URLs.
15
- 5. Bootstrap the infrastructure owner, then invite administrators and analysts from People. Invited users do not need Cloudflare accounts and can upload a JPEG, PNG, or WebP profile picture up to 2 MB. Names and workspace roles are always displayed separately.
16
- 6. Type the exact installation confirmation when setup requests it.
12
+ 2. Under Cloudflare Email ServiceEmail Sending, onboard the configured sender domain. Invitations and password recovery to arbitrary recipients depend on domain onboarding.
13
+ 3. Run `npm run setup`. The guided setup signs in through Wrangler and verifies Workers Paid from the account's Workers usage model before provisioning. Standard accounts need no separate billing token; only legacy or ambiguous models use the temporary Billing Read fallback. Setup then creates owner-only local secrets and walks through optional Google, GitHub, and Cloudflare OAuth credentials with exact callback URLs.
14
+ 4. Type the exact installation confirmation when setup requests it. After deployment, setup prints a 256-bit, single-use owner link that expires after 15 minutes and is never written to disk.
15
+ 5. Open the owner link, create the Safest owner account, then invite administrators and analysts from People. Nobody needs a Cloudflare account to sign in. Names and workspace roles are always displayed separately.
17
16
 
18
- Setup creates D1, four private R2 buckets, and three Queues, applies report migrations, and deploys the Worker with Dynamic Workflows. After bootstrap, verify the `database`, `workflow_runtime`, `workers_ai`, `object_storage`, and `queues` setup features, then require an authenticated `GET /ready` response of 200. Public `GET /health` is liveness only. Better Auth owns password hashing, email verification, OAuth accounts, sessions, and one-time password-reset tokens in D1. Upgrades always back up D1 before migrations and deployment.
17
+ Setup creates D1, four private R2 buckets, and three Queues, applies report migrations, and deploys the Worker with Dynamic Workflows. After owner setup, verify the `database`, `workflow_runtime`, `workers_ai`, `object_storage`, and `queues` setup features, then require an authenticated `GET /ready` response of 200. Public `GET /health` is liveness only. Better Auth owns password hashing, OAuth accounts, sessions, and one-time password-reset tokens in D1. Upgrades always back up D1 before migrations and deployment.
18
+
19
+ If the setup link expires, run `npm run owner:setup`. If the owner loses access later, authenticate Wrangler as the Cloudflare account owner and run `npm run owner:recover`; the resulting single-use link creates a 12-hour break-glass session and is recorded in the audit log.
19
20
 
20
21
  Administrators build and preview questions in **Configuration → Intake forms**, set the logo, colours, fonts, and shape in **Settings → Branding**, then choose a private hosted page, signed-in product widget, or anonymous product widget in **Settings → Reporting channels**. Hosted pages and anonymous widgets need no API key. Signed-in widgets use one small customer-backend endpoint; verified reporter, target, and registered trusted facts stay server-side while the browser receives only a short-lived opaque token. Every public submission is verified with Turnstile inside a Resolve-owned isolated frame.
21
22
 
@@ -30,6 +31,6 @@ npm run upgrade:plan
30
31
  npm run upgrade
31
32
  ```
32
33
 
33
- `npm run uninstall:plan` is deliberately read-only. It lists only the resources named by this installation and preserves D1, all private R2 buckets, and Access by default.
34
+ `npm run uninstall:plan` is deliberately read-only. It lists only the resources named by this installation and preserves D1 and all private R2 buckets by default.
34
35
 
35
36
  Verified backups contain the D1 export plus all objects from the four private R2 buckets, with a stable inventory and SHA-256 digests. They intentionally exclude pending Queue messages, live Workflow engine state, Worker secrets, external webhook state, and ephemeral analyst presence. Restore planning requires a separately named target installation and does not mutate Cloudflare.
@@ -4,6 +4,7 @@ import { removeAccountHash } from "./browser-navigation";
4
4
  import { authEvents, type AuthErrorDetail } from "./events";
5
5
  import { InvitationAcceptance } from "./InvitationAcceptance";
6
6
  import { OperatorLogin } from "./OperatorLogin";
7
+ import { OwnerSetup } from "./OwnerSetup";
7
8
  import { PasswordRecovery, PasswordReset } from "./PasswordRecovery";
8
9
 
9
10
  export function AccountJourney() {
@@ -63,6 +64,7 @@ export function AccountJourney() {
63
64
  {route.view === "forgot" ? <PasswordRecovery email={email} onEmailChange={(value) => { setEmail(value); setError(""); }} onBack={() => showLogin(false)} onError={setError} /> : null}
64
65
  {route.view === "reset" ? <PasswordReset token={route.token} onBack={() => showLogin(true)} onError={setError} /> : null}
65
66
  {route.view === "invite" ? <InvitationAcceptance token={route.token} onBack={() => showLogin(true)} onError={setError} /> : null}
67
+ {route.view === "setup" ? <OwnerSetup token={route.token} onBack={() => showLogin(true)} onError={setError} /> : null}
66
68
  <p id="operator-login-error" className="error auth-error" role="alert">{error}</p>
67
69
  </div>
68
70
  );
@@ -55,7 +55,7 @@ export function OperatorLogin({ email, emailInput, onEmailChange, onForgotPasswo
55
55
  <p className="eyebrow">Private safety workspace</p>
56
56
  <h2 id="login-title">Sign in to Safest</h2>
57
57
  <p id="operator-login-help" className="auth-copy">
58
- Admins and analysts use their invited Safest account. They do not need a Cloudflare account.
58
+ Owners, admins, and analysts use their Safest account. They do not need a Cloudflare account.
59
59
  </p>
60
60
  <OAuthButtons disabled={pending} onError={onError} />
61
61
  <form id="operator-login-form" aria-busy={pending} onSubmit={signIn}>
@@ -93,18 +93,6 @@ export function OperatorLogin({ email, emailInput, onEmailChange, onForgotPasswo
93
93
  <button id="forgot-password-link" className="text-button" type="button" onClick={requestPasswordReset}>
94
94
  Forgot password?
95
95
  </button>
96
- <details className="infrastructure-access">
97
- <summary>Infrastructure owner access</summary>
98
- <p>This separate route is only for the allowlisted infrastructure owner. Cloudflare Access handles authentication and recovery.</p>
99
- <button
100
- id="access-login"
101
- className="secondary wide"
102
- type="button"
103
- onClick={() => window.location.assign("/v1/infrastructure/bootstrap")}
104
- >
105
- Continue with Cloudflare Access
106
- </button>
107
- </details>
108
96
  </div>
109
97
  );
110
98
  }
@@ -0,0 +1,110 @@
1
+ import { useEffect, useState, type FormEvent } from "react";
2
+ import { authEvents, dispatchAuthEvent } from "./events";
3
+ import { replaceWithWorkspaceRoute } from "./browser-navigation";
4
+ import { errorMessage, postJson } from "../lib/http";
5
+ import { OAuthButtons } from "./OAuthButtons";
6
+
7
+ interface OwnerSetupProps {
8
+ token: string;
9
+ onBack: () => void;
10
+ onError: (message: string) => void;
11
+ }
12
+
13
+ interface OwnerSetupDetails {
14
+ email: string;
15
+ purpose: "bootstrap" | "recovery";
16
+ expires_at: string;
17
+ }
18
+
19
+ export function OwnerSetup({ token, onBack, onError }: OwnerSetupProps) {
20
+ const [details, setDetails] = useState<OwnerSetupDetails | null>(null);
21
+ const [displayName, setDisplayName] = useState("");
22
+ const [password, setPassword] = useState("");
23
+ const [pending, setPending] = useState(true);
24
+
25
+ useEffect(() => {
26
+ let active = true;
27
+ if (!token) {
28
+ setPending(false);
29
+ onError("This owner setup link is invalid or expired.");
30
+ return () => { active = false; };
31
+ }
32
+ void postJson<{ setup: OwnerSetupDetails }>("/v1/auth/owner-setup/inspect", { token })
33
+ .then((result) => {
34
+ if (!active) return;
35
+ setDetails(result.setup);
36
+ onError("");
37
+ window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}`);
38
+ })
39
+ .catch((cause) => {
40
+ if (active) onError(errorMessage(cause, "This owner setup link is invalid or expired."));
41
+ })
42
+ .finally(() => { if (active) setPending(false); });
43
+ return () => { active = false; };
44
+ }, [token, onError]);
45
+
46
+ async function finishSetup(event: FormEvent<HTMLFormElement>) {
47
+ event.preventDefault();
48
+ if (pending || !details) return;
49
+ setPending(true);
50
+ onError("");
51
+ try {
52
+ await postJson("/v1/auth/owner-setup/accept", details.purpose === "bootstrap"
53
+ ? { display_name: displayName.trim(), password }
54
+ : {});
55
+ setPassword("");
56
+ sessionStorage.removeItem("safest-reports-force-login");
57
+ replaceWithWorkspaceRoute();
58
+ dispatchAuthEvent(authEvents.authenticated, {
59
+ destination: details.purpose === "recovery" ? "profile" : "reports",
60
+ ...(details.purpose === "recovery" ? {
61
+ profileMessage: "Break-glass access is active for 12 hours. Reset your owner password now.",
62
+ } : {}),
63
+ });
64
+ } catch (cause) {
65
+ setPending(false);
66
+ onError(errorMessage(cause, "The owner account could not be set up."));
67
+ }
68
+ }
69
+
70
+ if (details?.purpose === "recovery") {
71
+ return (
72
+ <div className="auth-panel" data-auth-view="owner-recovery">
73
+ <p className="eyebrow">Owner recovery</p>
74
+ <h2 id="login-title">Start break-glass access</h2>
75
+ <p className="auth-copy">
76
+ Continue as {details.email} for 12 hours. Once signed in, reset the owner password and review the audit log.
77
+ </p>
78
+ <form aria-busy={pending} onSubmit={finishSetup}>
79
+ <button className="primary wide" type="submit" disabled={pending}>
80
+ {pending ? "Starting recovery…" : "Continue securely"}
81
+ </button>
82
+ </form>
83
+ <button className="text-button auth-back" type="button" onClick={onBack}>Back to sign in</button>
84
+ </div>
85
+ );
86
+ }
87
+
88
+ return (
89
+ <div className="auth-panel" data-auth-view="owner-setup">
90
+ <p className="eyebrow">One-time owner setup</p>
91
+ <h2 id="login-title">Create your owner account</h2>
92
+ <p className="auth-copy">
93
+ This short-lived link came from the authenticated installer. It works once and replaces the Cloudflare Access setup step.
94
+ </p>
95
+ {details ? <p className="form-message">Owner account for {details.email}</p> : null}
96
+ <OAuthButtons disabled={pending || !details} onError={onError} />
97
+ <form id="owner-setup-form" aria-busy={pending} onSubmit={finishSetup}>
98
+ <label htmlFor="owner-setup-name">Full name</label>
99
+ <input id="owner-setup-name" name="display_name" autoComplete="name" maxLength={100} value={displayName} onChange={(event) => setDisplayName(event.currentTarget.value)} disabled={pending || !details} required autoFocus />
100
+ <label htmlFor="owner-setup-password">Create password</label>
101
+ <input id="owner-setup-password" name="password" type="password" autoComplete="new-password" minLength={12} maxLength={128} value={password} onChange={(event) => setPassword(event.currentTarget.value)} disabled={pending || !details} required />
102
+ <small className="field-help">Use at least 12 characters. A passphrase works well.</small>
103
+ <button className="primary wide" type="submit" disabled={pending || !details}>
104
+ {pending ? "Checking secure link…" : "Create owner account"}
105
+ </button>
106
+ </form>
107
+ <button className="text-button auth-back" type="button" onClick={onBack}>Back to sign in</button>
108
+ </div>
109
+ );
110
+ }
@@ -1,4 +1,4 @@
1
- export type AccountView = "login" | "forgot" | "reset" | "invite";
1
+ export type AccountView = "login" | "forgot" | "reset" | "invite" | "setup";
2
2
 
3
3
  export interface AccountRoute {
4
4
  view: AccountView;
@@ -7,6 +7,8 @@ export interface AccountRoute {
7
7
 
8
8
  export function accountRouteFromHash(hash: string): AccountRoute {
9
9
  const parameters = new URLSearchParams(hash.replace(/^#/u, ""));
10
+ const setupToken = parameters.get("setup") || "";
11
+ if (setupToken) return { view: "setup", token: setupToken };
10
12
  const invitationToken = parameters.get("invite") || "";
11
13
  if (invitationToken) return { view: "invite", token: invitationToken };
12
14
  const resetToken = parameters.get("reset") || "";
@@ -30,7 +30,7 @@ export interface ConsoleSession {
30
30
  type: "operator_user";
31
31
  id: string;
32
32
  };
33
- authMethod: "cloudflare_access" | "better_auth";
33
+ authMethod: "owner_setup" | "better_auth";
34
34
  displayName: string;
35
35
  email?: string;
36
36
  avatarUrl?: string;
@@ -0,0 +1,29 @@
1
+ PRAGMA foreign_keys = ON;
2
+
3
+ CREATE TABLE owner_setup_tokens (
4
+ id TEXT PRIMARY KEY,
5
+ email TEXT NOT NULL COLLATE NOCASE,
6
+ token_hash TEXT NOT NULL UNIQUE,
7
+ purpose TEXT NOT NULL CHECK (purpose IN ('bootstrap', 'recovery')),
8
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'consumed', 'revoked')),
9
+ expires_at TEXT NOT NULL,
10
+ consumed_at TEXT,
11
+ revoked_at TEXT,
12
+ created_at TEXT NOT NULL
13
+ );
14
+
15
+ CREATE UNIQUE INDEX owner_setup_tokens_pending_email_purpose_idx
16
+ ON owner_setup_tokens(email, purpose)
17
+ WHERE status = 'pending';
18
+ CREATE INDEX owner_setup_tokens_expiry_idx
19
+ ON owner_setup_tokens(status, expires_at, created_at DESC, id);
20
+
21
+ CREATE TABLE owner_setup_rate_limits (
22
+ bucket_key TEXT PRIMARY KEY,
23
+ window_start TEXT NOT NULL,
24
+ request_count INTEGER NOT NULL CHECK (request_count > 0),
25
+ updated_at TEXT NOT NULL
26
+ );
27
+
28
+ CREATE INDEX owner_setup_rate_limits_expiry_idx
29
+ ON owner_setup_rate_limits(window_start, updated_at);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "safest-resolve-installation",
3
- "version": "0.2.2",
4
- "safestToolsVersion": "0.2.2-resolve",
3
+ "version": "0.3.0",
4
+ "safestToolsVersion": "0.3.0-resolve",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
7
7
  "type": "module",
@@ -11,6 +11,8 @@
11
11
  "secrets:init": "node scripts/reports-secrets.mjs",
12
12
  "setup": "npm run build:console && node scripts/reports-deploy.mjs",
13
13
  "setup:plan": "node scripts/reports-deploy.mjs --plan",
14
+ "owner:setup": "node scripts/reports-owner-setup.mjs",
15
+ "owner:recover": "node scripts/reports-owner-setup.mjs --recover",
14
16
  "upgrade": "npm run backup && npm run build:console && node scripts/reports-deploy.mjs --upgrade",
15
17
  "upgrade:plan": "node scripts/reports-deploy.mjs --upgrade --plan",
16
18
  "backup": "node scripts/reports-backup.mjs",
@@ -28,7 +30,6 @@
28
30
  "@clack/prompts": "^1.7.0",
29
31
  "@cloudflare/dynamic-workflows": "^0.1.1",
30
32
  "better-auth": "^1.7.2",
31
- "jose": "^6.2.10",
32
33
  "react": "^19.2.8",
33
34
  "react-dom": "^19.2.8"
34
35
  },