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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./reports.schema.json",
|
|
3
|
-
"schemaVersion":
|
|
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
|
-
"
|
|
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-
|
|
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", "
|
|
7
|
+
"required": ["schemaVersion", "installationId", "resources", "publicBaseUrl", "allowedOrigins", "owner", "email", "auth", "retention"],
|
|
8
8
|
"properties": {
|
|
9
9
|
"$schema": { "type": "string" },
|
|
10
|
-
"schemaVersion": { "const":
|
|
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
|
-
"
|
|
37
|
+
"owner": {
|
|
38
38
|
"type": "object",
|
|
39
39
|
"additionalProperties": false,
|
|
40
|
-
"required": ["
|
|
40
|
+
"required": ["email"],
|
|
41
41
|
"properties": {
|
|
42
|
-
"
|
|
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:
|
|
159
|
+
schemaVersion: 2,
|
|
159
160
|
installationId: config.installationId,
|
|
160
161
|
installedAt: new Date().toISOString(),
|
|
161
162
|
resources: config.resources,
|
|
162
|
-
|
|
163
|
+
ownerEmail: config.owner.email,
|
|
163
164
|
}, null, 2)}\n`, { mode: 0o600 });
|
|
164
|
-
|
|
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", "
|
|
72
|
-
if (input.schemaVersion !==
|
|
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
|
|
76
|
-
exactKeys(
|
|
77
|
-
const
|
|
78
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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", "
|
|
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
|
}
|
package/template/src/index.ts
CHANGED
|
@@ -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
|
|
926
|
-
if (request.method !== "
|
|
927
|
-
const
|
|
928
|
-
if (!
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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
|
|
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,
|