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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "./reports.schema.json",
3
- "schemaVersion": 1,
3
+ "schemaVersion": 2,
4
4
  "installationId": "acme-resolve",
5
5
  "resources": {
6
6
  "workerName": "acme-resolve-worker",
@@ -18,10 +18,7 @@
18
18
  "allowedOrigins": [
19
19
  "https://app.example.com"
20
20
  ],
21
- "access": {
22
- "audience": "replace-with-the-cloudflare-access-application-aud",
23
- "ownerEmails": ["infrastructure@example.com"]
24
- },
21
+ "owner": { "email": "infrastructure@example.com" },
25
22
  "email": {
26
23
  "enabled": true,
27
24
  "fromAddress": "reports@example.com"
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "$id": "https://safest.tools/schemas/reports-installation-v1.json",
3
+ "$id": "https://safest.tools/schemas/reports-installation-v2.json",
4
4
  "title": "Safest Resolve installation",
5
5
  "type": "object",
6
6
  "additionalProperties": false,
7
- "required": ["schemaVersion", "installationId", "resources", "publicBaseUrl", "allowedOrigins", "access", "email", "auth", "retention"],
7
+ "required": ["schemaVersion", "installationId", "resources", "publicBaseUrl", "allowedOrigins", "owner", "email", "auth", "retention"],
8
8
  "properties": {
9
9
  "$schema": { "type": "string" },
10
- "schemaVersion": { "const": 1 },
10
+ "schemaVersion": { "const": 2 },
11
11
  "installationId": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,62}$" },
12
12
  "resources": {
13
13
  "type": "object",
@@ -34,19 +34,12 @@
34
34
  "uniqueItems": true,
35
35
  "items": { "type": "string", "format": "uri", "pattern": "^https://" }
36
36
  },
37
- "access": {
37
+ "owner": {
38
38
  "type": "object",
39
39
  "additionalProperties": false,
40
- "required": ["audience", "ownerEmails"],
40
+ "required": ["email"],
41
41
  "properties": {
42
- "audience": { "type": "string", "minLength": 1, "maxLength": 200 },
43
- "ownerEmails": {
44
- "type": "array",
45
- "minItems": 1,
46
- "maxItems": 20,
47
- "uniqueItems": true,
48
- "items": { "type": "string", "format": "email" }
49
- }
42
+ "email": { "type": "string", "format": "email", "maxLength": 254 }
50
43
  }
51
44
  },
52
45
  "email": {
@@ -12,6 +12,7 @@ import {
12
12
  } from "./reports-cloudflare-preflight.mjs";
13
13
  import { loadReportsPlan } from "./reports-plan.mjs";
14
14
  import { initializeSecrets, parseSecrets } from "./reports-secrets.mjs";
15
+ import { issueOwnerSetup, printOwnerSetup } from "./reports-owner-setup.mjs";
15
16
 
16
17
  const projectRoot = fileURLToPath(new URL("..", import.meta.url));
17
18
  const wrangler = fileURLToPath(new URL("../node_modules/wrangler/bin/wrangler.js", import.meta.url));
@@ -155,13 +156,14 @@ export async function deployReports(argv = process.argv.slice(2)) {
155
156
  const installationPath = resolve(projectRoot, ".safest/installation.json");
156
157
  await mkdir(dirname(installationPath), { recursive: true, mode: 0o700 });
157
158
  await writeFile(installationPath, `${JSON.stringify({
158
- schemaVersion: 1,
159
+ schemaVersion: 2,
159
160
  installationId: config.installationId,
160
161
  installedAt: new Date().toISOString(),
161
162
  resources: config.resources,
162
- accessAudience: config.access.audience,
163
+ ownerEmail: config.owner.email,
163
164
  }, null, 2)}\n`, { mode: 0o600 });
164
- console.log("\nDeployment finished. Verify public /health, bootstrap the infrastructure owner through Access, run the authenticated setup feature checks until /ready succeeds, invite an administrator and analyst, then verify one server report and Queue consumption before production traffic.");
165
+ if (!options.upgrade) printOwnerSetup(await issueOwnerSetup({ config }));
166
+ console.log("\nDeployment finished. Open the one-time owner setup link, run the authenticated setup feature checks until /ready succeeds, invite an administrator and analyst, then verify one server report and Queue consumption before production traffic.");
165
167
  return { status: "deployed", plan };
166
168
  }
167
169
 
@@ -0,0 +1,132 @@
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { loadReportsPlan } from "./reports-plan.mjs";
6
+
7
+ const projectRoot = fileURLToPath(new URL("..", import.meta.url));
8
+ const wrangler = fileURLToPath(new URL("../node_modules/wrangler/bin/wrangler.js", import.meta.url));
9
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u;
10
+ const TOKEN_TTL_MINUTES = 15;
11
+
12
+ function sqlLiteral(value) {
13
+ return `'${String(value).replaceAll("'", "''")}'`;
14
+ }
15
+
16
+ function runWrangler(args) {
17
+ return new Promise((resolvePromise, reject) => {
18
+ const child = spawn(process.execPath, [wrangler, ...args], {
19
+ cwd: projectRoot,
20
+ env: { ...process.env, WRANGLER_LOG: "none", WRANGLER_WRITE_LOGS: "false" },
21
+ stdio: ["ignore", "pipe", "pipe"],
22
+ });
23
+ let stdout = "";
24
+ let stderr = "";
25
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
26
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
27
+ child.once("error", reject);
28
+ child.once("exit", (code, signal) => {
29
+ if (code === 0) return resolvePromise({ stdout, stderr });
30
+ reject(new Error(`Owner setup token creation failed${signal ? ` with signal ${signal}` : ` with exit code ${code}`}${stderr.trim() ? `: ${stderr.trim()}` : ""}`));
31
+ });
32
+ });
33
+ }
34
+
35
+ export function parseOwnerSetupArguments(argv) {
36
+ const result = { purpose: "bootstrap", email: "", config: "reports.config.json" };
37
+ for (let index = 0; index < argv.length; index += 1) {
38
+ const argument = argv[index];
39
+ if (argument === "--recover") result.purpose = "recovery";
40
+ else if (argument === "--email" || argument === "--config") {
41
+ const value = argv[index + 1];
42
+ if (!value || value.startsWith("-")) throw new Error(`${argument} needs a value`);
43
+ index += 1;
44
+ if (argument === "--email") result.email = value.trim().toLowerCase();
45
+ else result.config = value;
46
+ } else throw new Error(`unknown option: ${argument}`);
47
+ }
48
+ return result;
49
+ }
50
+
51
+ export function createOwnerSetupRecord({ email, purpose = "bootstrap", now = new Date(), token, id } = {}) {
52
+ const normalizedEmail = String(email ?? "").trim().toLowerCase();
53
+ if (!EMAIL.test(normalizedEmail) || normalizedEmail.length > 254) throw new Error("owner setup requires a valid email address");
54
+ if (purpose !== "bootstrap" && purpose !== "recovery") throw new Error("owner setup purpose must be bootstrap or recovery");
55
+ const rawToken = token ?? randomBytes(32).toString("base64url");
56
+ if (!/^[A-Za-z0-9_-]{43,200}$/u.test(rawToken)) throw new Error("owner setup token must contain at least 256 bits of base64url entropy");
57
+ const createdAt = new Date(now).toISOString();
58
+ return {
59
+ id: id ?? randomUUID(),
60
+ email: normalizedEmail,
61
+ purpose,
62
+ rawToken,
63
+ tokenHash: createHash("sha256").update(rawToken).digest("base64url"),
64
+ createdAt,
65
+ expiresAt: new Date(Date.parse(createdAt) + TOKEN_TTL_MINUTES * 60_000).toISOString(),
66
+ };
67
+ }
68
+
69
+ export function ownerSetupSql(record) {
70
+ const ownerGuard = record.purpose === "bootstrap"
71
+ ? `NOT EXISTS (SELECT 1 FROM operator_users WHERE status = 'active' AND roles_json LIKE '%"owner"%')`
72
+ : `EXISTS (SELECT 1 FROM operator_users WHERE email = ${sqlLiteral(record.email)} AND status = 'active' AND roles_json LIKE '%"owner"%')`;
73
+ return `
74
+ UPDATE owner_setup_tokens
75
+ SET status = 'revoked', revoked_at = ${sqlLiteral(record.createdAt)}
76
+ WHERE email = ${sqlLiteral(record.email)} AND purpose = ${sqlLiteral(record.purpose)} AND status = 'pending';
77
+ INSERT INTO owner_setup_tokens (id, email, token_hash, purpose, status, expires_at, created_at)
78
+ SELECT ${sqlLiteral(record.id)}, ${sqlLiteral(record.email)}, ${sqlLiteral(record.tokenHash)},
79
+ ${sqlLiteral(record.purpose)}, 'pending', ${sqlLiteral(record.expiresAt)}, ${sqlLiteral(record.createdAt)}
80
+ WHERE ${ownerGuard}
81
+ RETURNING id, email, purpose, expires_at;
82
+ `.trim();
83
+ }
84
+
85
+ function executionRows(stdout) {
86
+ let parsed;
87
+ try { parsed = JSON.parse(stdout.trim()); } catch { throw new Error("Wrangler returned unreadable D1 JSON while creating the owner setup token"); }
88
+ const results = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.result) ? parsed.result : [parsed];
89
+ return results.flatMap((entry) => Array.isArray(entry?.results) ? entry.results : []);
90
+ }
91
+
92
+ export async function issueOwnerSetup({ config, email, purpose = "bootstrap", runner = runWrangler, now, token, id } = {}) {
93
+ if (!config?.publicBaseUrl || !config?.owner?.email) throw new Error("owner setup requires a parsed Safest Resolve configuration");
94
+ const record = createOwnerSetupRecord({ email: email || config.owner.email, purpose, now, token, id });
95
+ const result = await runner(["d1", "execute", "DB", "--remote", "--command", ownerSetupSql(record), "--json", "--yes"]);
96
+ const created = executionRows(result.stdout).find((row) => row?.id === record.id);
97
+ if (!created) {
98
+ throw new Error(purpose === "bootstrap"
99
+ ? "An active infrastructure owner already exists. Use `npm run owner:recover` for break-glass access."
100
+ : `Recovery was refused because ${record.email} is not an active infrastructure owner.`);
101
+ }
102
+ return {
103
+ purpose,
104
+ email: record.email,
105
+ expiresAt: record.expiresAt,
106
+ url: `${new URL(config.publicBaseUrl).origin}/#setup=${encodeURIComponent(record.rawToken)}`,
107
+ };
108
+ }
109
+
110
+ export function printOwnerSetup(result, output = console) {
111
+ const label = result.purpose === "bootstrap" ? "Owner setup" : "Owner recovery";
112
+ output.log(`\n${label} link (single use; expires ${new Date(result.expiresAt).toLocaleString()}):\n${result.url}`);
113
+ output.log("\nThe secret is in the URL fragment, is not stored in Cloudflare logs, and was not written to disk.");
114
+ output.log(result.purpose === "bootstrap"
115
+ ? "If it expires, run `npm run owner:setup` to replace it."
116
+ : "This recovery link creates a 12-hour break-glass session. Reset the owner password after signing in.");
117
+ }
118
+
119
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
120
+ const options = parseOwnerSetupArguments(process.argv.slice(2));
121
+ loadReportsPlan(options.config).then(async ({ config }) => {
122
+ const result = await issueOwnerSetup({
123
+ config,
124
+ email: options.email || config.owner.email,
125
+ purpose: options.purpose,
126
+ });
127
+ printOwnerSetup(result);
128
+ }).catch((error) => {
129
+ console.error(error instanceof Error ? error.message : String(error));
130
+ process.exitCode = 1;
131
+ });
132
+ }
@@ -3,7 +3,6 @@ import { resolve } from "node:path";
3
3
 
4
4
  const RESOURCE_NAME = /^[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/u;
5
5
  const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u;
6
- const ACCESS_AUD = /^[a-f0-9]{64}$/iu;
7
6
  const AUTH_PROVIDERS = new Set(["google", "github", "cloudflare"]);
8
7
  const AUTH_SECRET_NAMES = {
9
8
  google: ["AUTH_GOOGLE_CLIENT_ID", "AUTH_GOOGLE_CLIENT_SECRET"],
@@ -68,17 +67,14 @@ function stringList(value, field, maximum, parse) {
68
67
 
69
68
  export function parseReportsConfig(value) {
70
69
  const input = object(value, "configuration");
71
- exactKeys(input, new Set(["$schema", "schemaVersion", "installationId", "resources", "publicBaseUrl", "allowedOrigins", "access", "email", "auth", "retention"]), "configuration");
72
- if (input.schemaVersion !== 1) throw new ReportsConfigError("schemaVersion must be 1");
70
+ exactKeys(input, new Set(["$schema", "schemaVersion", "installationId", "resources", "publicBaseUrl", "allowedOrigins", "owner", "email", "auth", "retention"]), "configuration");
71
+ if (input.schemaVersion !== 2) throw new ReportsConfigError("schemaVersion must be 2");
73
72
  const resources = object(input.resources, "resources");
74
73
  exactKeys(resources, new Set(["workerName", "databaseName", "profileMediaBucketName", "workflowArtifactsBucketName", "evidenceBucketName", "exportsBucketName", "workflowName", "reportQueueName", "deliveryQueueName", "operationsDlqName"]), "resources");
75
- const access = object(input.access, "access");
76
- exactKeys(access, new Set(["audience", "ownerEmails"]), "access");
77
- const ownerEmails = stringList(access.ownerEmails, "access.ownerEmails", 20, (email, field) => {
78
- const result = text(email, field, 254).toLowerCase();
79
- if (!EMAIL.test(result)) throw new ReportsConfigError(`${field} is invalid`);
80
- return result;
81
- });
74
+ const owner = object(input.owner, "owner");
75
+ exactKeys(owner, new Set(["email"]), "owner");
76
+ const ownerEmail = text(owner.email, "owner.email", 254).toLowerCase();
77
+ if (!EMAIL.test(ownerEmail)) throw new ReportsConfigError("owner.email is invalid");
82
78
  const retention = object(input.retention, "retention");
83
79
  exactKeys(retention, new Set(["reportsDays", "messagesDays", "appealWindowDays"]), "retention");
84
80
  const email = input.email === undefined ? { enabled: false, fromAddress: "" } : object(input.email, "email");
@@ -99,7 +95,7 @@ export function parseReportsConfig(value) {
99
95
  }
100
96
  if (new Set(authProviders).size !== authProviders.length) throw new ReportsConfigError("auth.providers cannot contain duplicates");
101
97
  const parsed = {
102
- schemaVersion: 1,
98
+ schemaVersion: 2,
103
99
  installationId: resourceName(input.installationId, "installationId"),
104
100
  resources: {
105
101
  workerName: resourceName(resources.workerName, "resources.workerName"),
@@ -115,10 +111,7 @@ export function parseReportsConfig(value) {
115
111
  },
116
112
  publicBaseUrl: origin(input.publicBaseUrl, "publicBaseUrl"),
117
113
  allowedOrigins: stringList(input.allowedOrigins, "allowedOrigins", 20, origin),
118
- access: {
119
- audience: text(access.audience, "access.audience", 200),
120
- ownerEmails,
121
- },
114
+ owner: { email: ownerEmail },
122
115
  email: { enabled: email.enabled, fromAddress: emailFromAddress },
123
116
  auth: { password: true, providers: authProviders },
124
117
  retention: {
@@ -133,19 +126,16 @@ export function parseReportsConfig(value) {
133
126
  }
134
127
 
135
128
  export function buildReportsPlan(config) {
136
- const accessReady = ACCESS_AUD.test(config.access.audience);
137
129
  const emailReady = config.email.enabled;
138
130
  const publicHostname = new URL(config.publicBaseUrl).hostname;
139
- const ownerBootstrapPath = `${publicHostname}/v1/infrastructure/*`;
140
131
  const customDomain = publicHostname.endsWith(".workers.dev") ? null : publicHostname;
141
132
  return {
142
133
  product: "Safest Resolve",
143
- schemaVersion: 1,
134
+ schemaVersion: 2,
144
135
  installationId: config.installationId,
145
136
  ownership: "All resources, data, secrets, policies, and logs remain in the customer's Cloudflare account.",
146
- ready: accessReady && emailReady,
137
+ ready: emailReady,
147
138
  blockers: [
148
- ...(!accessReady ? ["Create the customer-owned Cloudflare Access application and replace access.audience with its 64-character AUD."] : []),
149
139
  ...(!emailReady ? ["Onboard the sender domain in Cloudflare Email Sending and set email.fromAddress; invitations and password recovery cannot be deployed without email delivery."] : []),
150
140
  ],
151
141
  resources: {
@@ -171,11 +161,10 @@ export function buildReportsPlan(config) {
171
161
  oauthProviders: config.auth.providers,
172
162
  callbackUrls: Object.fromEntries(config.auth.providers.map((provider) => [provider, `${config.publicBaseUrl}/api/auth/callback/${provider}`])),
173
163
  },
174
- access: {
175
- applicationPath: ownerBootstrapPath,
176
- audience: accessReady ? config.access.audience : "not configured",
177
- ownerEmails: config.access.ownerEmails,
178
- purpose: "Infrastructure-owner bootstrap and recovery only. Invited admins and analysts use Safest accounts.",
164
+ ownerSetup: {
165
+ email: config.owner.email,
166
+ bootstrap: "A 256-bit, single-use setup link is created after deployment and expires after 15 minutes.",
167
+ recovery: "Wrangler-authenticated npm run owner:recover creates a 12-hour break-glass session.",
179
168
  },
180
169
  },
181
170
  networkContract: {
@@ -185,14 +174,13 @@ export function buildReportsPlan(config) {
185
174
  reportSubmissionOnly: true,
186
175
  },
187
176
  order: [
188
- "Review this plan, the infrastructure-owner allowlist, and the Access application path.",
189
- "Create or select the Cloudflare Access application for /v1/infrastructure/* and record its AUD.",
177
+ "Review this plan and the initial infrastructure-owner email.",
190
178
  "Onboard the sender domain to Cloudflare Email Service before deployment.",
191
179
  "Initialize local secrets without committing them.",
192
180
  "Create D1, four private R2 buckets, and the three Queues in the customer account.",
193
181
  "Apply all report migrations before accepting traffic.",
194
182
  `Deploy the Worker, assets, Worker Loader, Dynamic Workflow, presence Durable Object, Queue consumers, cron, Workers AI binding${customDomain ? `, and ${customDomain} Custom Domain` : ""}.`,
195
- "Bootstrap the infrastructure owner, verify required setup features and authenticated /ready, invite an administrator, invite an analyst, then verify report intake, routing, and delivery recovery.",
183
+ "Open the generated one-time owner setup link, create the owner account, verify required setup features and authenticated /ready, invite an administrator and analyst, then verify report intake, routing, and delivery recovery.",
196
184
  ],
197
185
  lifecycle: {
198
186
  backup: "D1 SQL export plus all four private R2 buckets, stable inventory, per-object SHA-256 digests, and a durable verification record; Queue and live Workflow state remain separate.",
@@ -10,7 +10,7 @@ export async function buildReportsUninstallPlan() {
10
10
  "Queue backlog and dead letters",
11
11
  "Worker service bindings and custom domains",
12
12
  "verified D1 and R2 backup manifests and checksums",
13
- "Access application policies",
13
+ "outstanding owner setup or recovery tokens",
14
14
  "enrichment, notification, and action webhook dependencies",
15
15
  ],
16
16
  exactResources: {
@@ -19,9 +19,8 @@ export async function buildReportsUninstallPlan() {
19
19
  database: config.resources.databaseName,
20
20
  r2Buckets: [config.resources.profileMediaBucketName, config.resources.workflowArtifactsBucketName, config.resources.evidenceBucketName, config.resources.exportsBucketName],
21
21
  dynamicWorkflow: config.resources.workflowName,
22
- accessAudience: config.access.audience,
23
22
  },
24
- defaultPreservation: ["D1 database", "all private R2 buckets", "Access application", "independent secrets recovery", "verified D1/R2 archive manifests"],
23
+ defaultPreservation: ["D1 database", "all private R2 buckets", "independent secrets recovery", "verified D1/R2 archive manifests"],
25
24
  note: "Removal stays manual so data, dependencies, and exact resource ownership can be verified immediately beforehand.",
26
25
  };
27
26
  }
@@ -18,7 +18,6 @@ import {
18
18
  projectOperatorFields,
19
19
  requireIntegration,
20
20
  requireOperator,
21
- resolveInfrastructureOwnerIdentity,
22
21
  appendOperatorAuthenticationCookies,
23
22
  resolveOperatorAuthentication,
24
23
  resolveOperatorSession,
@@ -26,10 +25,13 @@ import {
26
25
  import {
27
26
  callBetterAuth,
28
27
  configuredAuthProviders,
28
+ clearOwnerSetupCookie,
29
29
  handleBetterAuthRequest,
30
30
  inspectInvitation,
31
+ inspectOwnerSetup,
31
32
  invitationAllowsEmail,
32
33
  invitationIdFromRequest,
34
+ ownerSetupContextFromRequest,
33
35
  revokeBetterAuthSessionsForOperator,
34
36
  sendOperatorInvitationEmail,
35
37
  } from "./report-better-auth";
@@ -121,7 +123,6 @@ import {
121
123
  } from "./report-operations";
122
124
  import {
123
125
  appendOperatorSessionCookies,
124
- bootstrapOwner,
125
126
  clearOperatorSessionCookies,
126
127
  createOperatorInvitation,
127
128
  deleteOperatorProfilePicture,
@@ -130,6 +131,7 @@ import {
130
131
  operatorProfilePictureResponse,
131
132
  revokeOperatorInvitation,
132
133
  revokeOperatorSessions,
134
+ recoverOwnerWithSetup,
133
135
  resolveBetterAuthOperator,
134
136
  updateOperatorUser,
135
137
  updateOperatorProfile,
@@ -922,18 +924,52 @@ async function handleOperatorLogout(request: Request, env: Env, ctx: ExecutionCo
922
924
  return clearOperatorSessionCookies(response, request);
923
925
  }
924
926
 
925
- async function handleInfrastructureBootstrap(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
926
- if (request.method !== "GET" && request.method !== "POST") return methodNotAllowed(["GET", "POST"]);
927
- const identity = await resolveInfrastructureOwnerIdentity(request, env, ctx);
928
- if (!identity) throw new ApiError(401, "infrastructure_authentication_required", "Cloudflare Access authentication is required for infrastructure owner setup.");
929
- const issued = await bootstrapOwner(env.DB, identity);
930
- if (request.method === "POST") {
931
- return appendOperatorSessionCookies(json({ schema_version: "1", bootstrapped: true }, { status: 201 }), issued, request);
927
+ async function handleOwnerSetupAccept(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
928
+ if (request.method !== "POST") return methodNotAllowed(["POST"]);
929
+ const setup = await ownerSetupContextFromRequest(request, env);
930
+ if (!setup) throw new ApiError(401, "invalid_owner_setup", "This owner setup link is invalid or expired.");
931
+ if (setup.purpose === "recovery") {
932
+ const issued = await recoverOwnerWithSetup(env.DB, setup.id, setup.email);
933
+ if (!issued) throw new ApiError(409, "owner_recovery_unavailable", "This owner recovery link has already been used or is no longer valid.");
934
+ return clearOwnerSetupCookie(appendOperatorSessionCookies(json({
935
+ schema_version: "1",
936
+ authenticated: true,
937
+ recovery_session_expires_at: issued.expiresAt,
938
+ }, { status: 201 }), issued, request), request);
932
939
  }
933
- return appendOperatorSessionCookies(new Response(null, {
934
- status: 303,
935
- headers: { location: "/#reports", "cache-control": "no-store" },
936
- }), issued, request);
940
+ const body = await readJsonObject(request, 8 * 1024);
941
+ const displayName = typeof body.display_name === "string" ? body.display_name.trim() : "";
942
+ const signUp = await callBetterAuth(request, env, ctx, "/sign-up/email", {
943
+ name: displayName,
944
+ email: setup.email,
945
+ password: body.password,
946
+ callbackURL: "/",
947
+ rememberMe: false,
948
+ });
949
+ if (!signUp.ok) return normalizedBetterAuthError(signUp, "The owner account could not be created.");
950
+ signUp.body?.cancel();
951
+ await env.DB.prepare(`UPDATE auth_user SET emailVerified = 1, updatedAt = ?2 WHERE email = ?1 COLLATE NOCASE`)
952
+ .bind(setup.email, new Date().toISOString()).run();
953
+ const signIn = await callBetterAuth(request, env, ctx, "/sign-in/email", {
954
+ email: setup.email,
955
+ password: body.password,
956
+ rememberMe: false,
957
+ callbackURL: "/",
958
+ });
959
+ if (!signIn.ok) return normalizedBetterAuthError(signIn, "The owner account could not be activated.");
960
+ const owner = await resolveBetterAuthOperator(env.DB, {
961
+ email: setup.email,
962
+ name: displayName || setup.email.split("@", 1)[0],
963
+ }, null, setup.id);
964
+ if (!owner?.roles.includes("owner")) {
965
+ signIn.body?.cancel();
966
+ throw new ApiError(409, "owner_setup_already_used", "This owner setup link has already been used or is no longer valid.");
967
+ }
968
+ return clearOwnerSetupCookie(normalizedBetterAuthSuccess(signIn, {
969
+ schema_version: "1",
970
+ account_created: true,
971
+ authenticated: true,
972
+ }, 201), request);
937
973
  }
938
974
 
939
975
  async function handleAdminPeople(request: Request, env: Env): Promise<Response> {
@@ -1004,7 +1040,7 @@ async function handleAdminOperatorPasswordReset(
1004
1040
  throw new ApiError(
1005
1041
  409,
1006
1042
  "password_authentication_unavailable",
1007
- "This account uses OAuth or Cloudflare Access and does not have a Safest password to reset.",
1043
+ "This account uses OAuth and does not have a Safest password to reset.",
1008
1044
  );
1009
1045
  }
1010
1046
  const better = await callBetterAuth(request, env, ctx, "/request-password-reset", {
@@ -3452,6 +3488,8 @@ async function route(request: Request, env: Env, ctx: ExecutionContext): Promise
3452
3488
  if (url.pathname === "/v1/meta/capabilities") return handleMetaCapabilities(request, env);
3453
3489
  if (url.pathname === "/v1/meta/version") return handleMetaVersion(request, env);
3454
3490
  if (url.pathname === "/v1/auth/providers") return handleAuthProviders(request, env);
3491
+ if (url.pathname === "/v1/auth/owner-setup/inspect") return inspectOwnerSetup(request, env);
3492
+ if (url.pathname === "/v1/auth/owner-setup/accept") return handleOwnerSetupAccept(request, env, ctx);
3455
3493
  if (url.pathname === "/v1/auth/login") return handleOperatorLogin(request, env, ctx);
3456
3494
  if (url.pathname === "/v1/auth/invitations/inspect") return inspectInvitation(request, env);
3457
3495
  if (url.pathname === "/v1/auth/invitations/accept") return handleOperatorInvitationAccept(request, env, ctx);
@@ -3460,7 +3498,6 @@ async function route(request: Request, env: Env, ctx: ExecutionContext): Promise
3460
3498
  if (url.pathname === "/v1/auth/logout") return handleOperatorLogout(request, env, ctx);
3461
3499
  if (url.pathname === "/v1/account/profile") return handleOperatorProfile(request, env);
3462
3500
  if (url.pathname === "/v1/account/profile-picture") return handleOperatorProfilePicture(request, env, ctx);
3463
- if (url.pathname === "/v1/infrastructure/bootstrap") return handleInfrastructureBootstrap(request, env, ctx);
3464
3501
  if (url.pathname === "/v1/widget/config") return handleWidgetConfig(request, env);
3465
3502
  if (url.pathname === "/v1/brand") return handlePublicBrand(request, env);
3466
3503
  if (url.pathname === "/v1/public/config") return handlePublicConfig(request, env);
@@ -1,4 +1,3 @@
1
- import { createRemoteJWKSet, decodeJwt, jwtVerify, type JWTPayload } from "jose";
2
1
  import { ApiError } from "./report-http";
3
2
  import { secretsEqual, sha256 } from "./report-crypto";
4
3
  import {
@@ -12,82 +11,12 @@ import {
12
11
  betterAuthCsrfHash,
13
12
  betterAuthCsrfToken,
14
13
  invitationIdFromRequest,
14
+ ownerSetupContextFromRequest,
15
15
  resolveBetterAuthSession,
16
16
  } from "./report-better-auth";
17
17
  import { loadIntegration } from "./report-repository";
18
18
  import type { OperatorPermissions, OperatorSession } from "./report-types";
19
19
 
20
- type AccessIdentity = JWTPayload & {
21
- email?: unknown;
22
- name?: unknown;
23
- picture?: unknown;
24
- user_uuid?: unknown;
25
- groups?: unknown;
26
- custom?: unknown;
27
- };
28
- type AccessContext = Pick<ExecutionContext, "access">;
29
-
30
- const accessKeySets = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
31
- function bounded(value: unknown, maximum: number): string | undefined {
32
- if (typeof value !== "string") return undefined;
33
- const normalized = value.trim();
34
- return normalized ? normalized.slice(0, maximum) : undefined;
35
- }
36
-
37
- function avatarUrl(value: unknown): string | undefined {
38
- const candidate = bounded(value, 2_048);
39
- if (!candidate) return undefined;
40
- try {
41
- const url = new URL(candidate);
42
- return url.protocol === "https:" && !url.username && !url.password ? url.href : undefined;
43
- } catch {
44
- return undefined;
45
- }
46
- }
47
-
48
- function issuer(value: unknown): string | null {
49
- if (typeof value !== "string") return null;
50
- try {
51
- const url = new URL(value);
52
- return url.protocol === "https:" && !url.username && !url.password && !url.port
53
- && (url.pathname === "/" || url.pathname === "") && !url.search && !url.hash
54
- && url.hostname.endsWith(".cloudflareaccess.com")
55
- ? url.origin
56
- : null;
57
- } catch {
58
- return null;
59
- }
60
- }
61
-
62
- async function accessIdentity(request: Request, env: Env, ctx: AccessContext): Promise<AccessIdentity | null> {
63
- if (ctx.access) {
64
- try {
65
- return await ctx.access.getIdentity() as AccessIdentity;
66
- } catch (error) {
67
- console.warn("Cloudflare Access runtime identity was unavailable", error instanceof Error ? error.message : typeof error);
68
- }
69
- }
70
- const token = request.headers.get("cf-access-jwt-assertion");
71
- const audience = bounded(env.ACCESS_AUD, 200);
72
- if (!token || !audience) return null;
73
- try {
74
- const tokenIssuer = issuer(decodeJwt(token).iss);
75
- if (!tokenIssuer) return null;
76
- let keySet = accessKeySets.get(tokenIssuer);
77
- if (!keySet) {
78
- keySet = createRemoteJWKSet(new URL("/cdn-cgi/access/certs", tokenIssuer));
79
- accessKeySets.set(tokenIssuer, keySet);
80
- }
81
- const { payload } = await jwtVerify(token, keySet, {
82
- algorithms: ["RS256"], audience, issuer: tokenIssuer,
83
- });
84
- return payload as AccessIdentity;
85
- } catch (error) {
86
- console.warn("Cloudflare Access JWT validation failed", error instanceof Error ? error.message : typeof error);
87
- return null;
88
- }
89
- }
90
-
91
20
  export async function resolveOperatorSession(request: Request, env: Env): Promise<OperatorSession | null> {
92
21
  return (await resolveOperatorAuthentication(request, env))?.session ?? null;
93
22
  }
@@ -111,7 +40,7 @@ export async function resolveOperatorAuthentication(
111
40
  email: better.user.email,
112
41
  name: better.user.name,
113
42
  image: better.user.image,
114
- }, await invitationIdFromRequest(request, env));
43
+ }, await invitationIdFromRequest(request, env), (await ownerSetupContextFromRequest(request, env))?.id ?? null);
115
44
  if (session) {
116
45
  const csrfToken = await betterAuthCsrfToken(env, better.session.id);
117
46
  return {
@@ -148,26 +77,6 @@ export function appendOperatorAuthenticationCookies(
148
77
  : response;
149
78
  }
150
79
 
151
- export async function resolveInfrastructureOwnerIdentity(
152
- request: Request,
153
- env: Env,
154
- ctx: AccessContext,
155
- ): Promise<{ email: string; displayName: string; avatarUrl?: string } | null> {
156
- const identity = await accessIdentity(request, env, ctx);
157
- if (!identity) return null;
158
- const email = bounded(identity.email, 254)?.toLowerCase();
159
- if (!email) return null;
160
- const allowedOwners = new Set((env.INFRASTRUCTURE_OWNER_EMAILS ?? "").split(",")
161
- .map((value) => value.trim().toLowerCase()).filter(Boolean));
162
- if (!allowedOwners.has(email)) return null;
163
- const picture = avatarUrl(identity.picture);
164
- return {
165
- email,
166
- displayName: bounded(identity.name, 100) ?? email,
167
- ...(picture ? { avatarUrl: picture } : {}),
168
- };
169
- }
170
-
171
80
  export async function requireOperator(
172
81
  request: Request,
173
82
  env: Env,