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 +7 -11
- package/package.json +1 -1
- package/src/cli.mjs +12 -33
- package/src/config.mjs +4 -16
- package/template/README.md +9 -8
- package/template/console/auth/AccountJourney.tsx +2 -0
- package/template/console/auth/OperatorLogin.tsx +1 -13
- package/template/console/auth/OwnerSetup.tsx +110 -0
- package/template/console/auth/account-route.ts +3 -1
- package/template/console/shell/types.ts +1 -1
- package/template/migrations/0042_owner_setup_tokens.sql +29 -0
- package/template/package.json +4 -3
- package/template/public/console/auth-shell.js +3118 -2972
- package/template/reports.config.example.json +2 -5
- package/template/reports.schema.json +6 -13
- package/template/scripts/reports-deploy.mjs +5 -3
- package/template/scripts/reports-owner-setup.mjs +132 -0
- package/template/scripts/reports-plan.mjs +16 -28
- package/template/scripts/reports-uninstall-plan.mjs +2 -3
- package/template/src/index.ts +52 -15
- package/template/src/report-auth.ts +2 -93
- package/template/src/report-better-auth.ts +149 -1
- package/template/src/report-operator-accounts.ts +126 -39
- package/template/src/report-types.ts +1 -1
- package/template/worker-configuration.d.ts +2 -4
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
|
|
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
|
-
--
|
|
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
|
-
|
|
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,
|
|
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
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
|
-
--
|
|
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
|
-
--
|
|
45
|
-
--
|
|
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
|
-
|
|
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", "--
|
|
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 === "--
|
|
91
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
43
|
-
if (!
|
|
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:
|
|
58
|
+
schemaVersion: 2,
|
|
66
59
|
installationId,
|
|
67
60
|
resources,
|
|
68
61
|
publicBaseUrl,
|
|
69
62
|
allowedOrigins,
|
|
70
|
-
|
|
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: "",
|
package/template/README.md
CHANGED
|
@@ -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
|
|
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.
|
|
13
|
-
3.
|
|
14
|
-
4.
|
|
15
|
-
5.
|
|
16
|
-
6. Type the exact installation confirmation when setup requests it.
|
|
12
|
+
2. Under Cloudflare Email Service → Email 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
|
|
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
|
|
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
|
-
|
|
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") || "";
|
|
@@ -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);
|
package/template/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safest-resolve-installation",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"safestToolsVersion": "0.
|
|
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
|
},
|