feat: release OTP and Passkey Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { lstat, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { format } from "prettier";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const sourcePath = join(root, "src", "toolbox", "manifest.source.json");
|
||||
const outputPath = join(root, "public", "toolbox-app.json");
|
||||
const source = JSON.parse(await readFile(sourcePath, "utf8"));
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(join(root, "package.json"), "utf8"),
|
||||
);
|
||||
const versionSource = await readFile(join(root, "src", "version.ts"), "utf8");
|
||||
const applicationVersion =
|
||||
/^export const APPLICATION_VERSION = "([^"]+)";$/mu.exec(versionSource)?.[1];
|
||||
if (
|
||||
source.version !== packageJson.version ||
|
||||
applicationVersion !== packageJson.version
|
||||
)
|
||||
throw new Error(
|
||||
`Version drift: manifest ${source.version}, application ${String(applicationVersion)}, package ${packageJson.version}`,
|
||||
);
|
||||
if (
|
||||
source.id !== "de.add-ideas.auth-tools" ||
|
||||
source.source?.repository !== "https://git.add-ideas.de/lotobo/auth-tools" ||
|
||||
source.source?.license !== "GPL-3.0-or-later"
|
||||
)
|
||||
throw new Error("Manifest source identity is incomplete or inconsistent");
|
||||
for (const asset of source.assets ?? []) {
|
||||
if (
|
||||
typeof asset !== "string" ||
|
||||
!asset.startsWith("./") ||
|
||||
asset.includes("\\") ||
|
||||
asset.split("/").includes("..")
|
||||
)
|
||||
throw new Error(`Unsafe manifest asset path: ${JSON.stringify(asset)}`);
|
||||
const details = await lstat(join(root, "public", asset.slice(2))).catch(
|
||||
() => null,
|
||||
);
|
||||
if (!details?.isFile() || details.isSymbolicLink())
|
||||
throw new Error(`Manifest asset is missing or unsafe: ${asset}`);
|
||||
}
|
||||
const serialized = await format(JSON.stringify(source), {
|
||||
filepath: outputPath,
|
||||
});
|
||||
if (process.argv.includes("--check")) {
|
||||
if ((await readFile(outputPath, "utf8").catch(() => "")) !== serialized)
|
||||
throw new Error(
|
||||
`${relative(root, outputPath)} is stale; run npm run manifest:generate`,
|
||||
);
|
||||
console.log("Toolbox manifest is synchronized");
|
||||
} else {
|
||||
await writeFile(outputPath, serialized);
|
||||
console.log(`Generated ${relative(root, outputPath)}`);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import {
|
||||
access,
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
utimes,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.join(root, "package.json"), "utf8"),
|
||||
);
|
||||
const argument = (name, fallback) => {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : fallback;
|
||||
};
|
||||
const output = path.resolve(
|
||||
root,
|
||||
argument("--output", `release/auth-tools-${packageJson.version}.zip`),
|
||||
);
|
||||
const checksumOutput = `${output}.sha256`;
|
||||
const force = process.argv.includes("--force");
|
||||
if (
|
||||
path.extname(output).toLowerCase() !== ".zip" ||
|
||||
output === root ||
|
||||
output === path.parse(output).root
|
||||
)
|
||||
throw new Error("Release output is not a safe ZIP target");
|
||||
const exists = (file) =>
|
||||
access(file).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (!force && ((await exists(output)) || (await exists(checksumOutput))))
|
||||
throw new Error("Release output already exists; use --force to replace it");
|
||||
|
||||
const input = path.join(root, "dist");
|
||||
for (const name of [
|
||||
"index.html",
|
||||
"toolbox-app.json",
|
||||
"favicon.svg",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"LICENSE",
|
||||
"SOURCE.md",
|
||||
"SECURITY.md",
|
||||
"THIRD_PARTY_NOTICES.md",
|
||||
"LICENSES/README.md",
|
||||
"LICENSES/npm-runtime-licenses.txt",
|
||||
]) {
|
||||
const details = await lstat(path.join(input, name)).catch(() => null);
|
||||
if (!details?.isFile() || details.isSymbolicLink())
|
||||
throw new Error(`Release is missing a regular file: ${name}`);
|
||||
}
|
||||
const manifest = JSON.parse(
|
||||
await readFile(path.join(input, "toolbox-app.json"), "utf8"),
|
||||
);
|
||||
if (
|
||||
manifest.id !== "de.add-ideas.auth-tools" ||
|
||||
manifest.version !== packageJson.version ||
|
||||
manifest.entry !== "./" ||
|
||||
manifest.icon !== "./favicon.svg"
|
||||
)
|
||||
throw new Error("Packaged Toolbox manifest identity is invalid");
|
||||
const html = await readFile(path.join(input, "index.html"), "utf8");
|
||||
if (/\b(?:src|href)=["']\//iu.test(html))
|
||||
throw new Error("index.html contains a root-absolute asset reference");
|
||||
|
||||
async function collect(directory, prefix = "") {
|
||||
const files = [];
|
||||
for (const entry of (await readdir(directory, { withFileTypes: true })).sort(
|
||||
(left, right) => left.name.localeCompare(right.name),
|
||||
)) {
|
||||
const absolute = path.join(directory, entry.name);
|
||||
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
if (entry.isSymbolicLink())
|
||||
throw new Error(`Release contains a symbolic link: ${relative}`);
|
||||
if (entry.isDirectory()) files.push(...(await collect(absolute, relative)));
|
||||
else if (entry.isFile()) files.push({ absolute, relative });
|
||||
else throw new Error(`Unsupported release entry: ${relative}`);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
const sourceFiles = await collect(input);
|
||||
for (const file of sourceFiles)
|
||||
if (
|
||||
file.relative.endsWith(".map") ||
|
||||
/(?:^|\/)(?:\.env(?:\.|$)|id_rsa|id_ed25519|.*\.pem$|.*\.key$)/iu.test(
|
||||
file.relative,
|
||||
) ||
|
||||
file.relative.startsWith("/") ||
|
||||
file.relative.split("/").includes("..")
|
||||
)
|
||||
throw new Error(`Forbidden release entry: ${file.relative}`);
|
||||
|
||||
await mkdir(path.dirname(output), { recursive: true });
|
||||
const stagingRoot = await mkdtemp(
|
||||
path.join(path.dirname(output), ".auth-release-"),
|
||||
);
|
||||
const stagedTree = path.join(stagingRoot, "tree");
|
||||
const stagedArchive = path.join(stagingRoot, path.basename(output));
|
||||
try {
|
||||
await cp(input, stagedTree, { recursive: true });
|
||||
const timestamp = new Date("1980-01-01T00:00:00.000Z");
|
||||
for (const file of await collect(stagedTree))
|
||||
await utimes(file.absolute, timestamp, timestamp);
|
||||
await execute(
|
||||
"zip",
|
||||
[
|
||||
"-X",
|
||||
"-q",
|
||||
"-9",
|
||||
stagedArchive,
|
||||
...sourceFiles.map((file) => file.relative),
|
||||
],
|
||||
{ cwd: stagedTree, maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
const archive = await readFile(stagedArchive);
|
||||
const digest = createHash("sha256").update(archive).digest("hex");
|
||||
const stagedChecksum = `${stagedArchive}.sha256`;
|
||||
await writeFile(stagedChecksum, `${digest} ${path.basename(output)}\n`, {
|
||||
mode: 0o644,
|
||||
});
|
||||
if (force) {
|
||||
await rm(output, { force: true });
|
||||
await rm(checksumOutput, { force: true });
|
||||
}
|
||||
await rename(stagedArchive, output);
|
||||
await rename(stagedChecksum, checksumOutput);
|
||||
console.log(
|
||||
`Created ${path.relative(root, output)} (${archive.byteLength} bytes, ${sourceFiles.length} files)\nSHA-256 ${digest}`,
|
||||
);
|
||||
} finally {
|
||||
await rm(stagingRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const destination = path.join(root, "public");
|
||||
const required = [
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"SOURCE.md",
|
||||
"SECURITY.md",
|
||||
"THIRD_PARTY_NOTICES.md",
|
||||
];
|
||||
await mkdir(destination, { recursive: true });
|
||||
for (const name of required) {
|
||||
await readFile(path.join(root, name));
|
||||
await cp(path.join(root, name), path.join(destination, name));
|
||||
}
|
||||
const publicLicenses = path.join(destination, "LICENSES");
|
||||
await rm(publicLicenses, { recursive: true, force: true });
|
||||
await cp(path.join(root, "LICENSES"), publicLicenses, { recursive: true });
|
||||
|
||||
const lock = JSON.parse(
|
||||
await readFile(path.join(root, "package-lock.json"), "utf8"),
|
||||
);
|
||||
const sections = [];
|
||||
for (const [location, locked] of Object.entries(lock.packages ?? {}).sort(
|
||||
([left], [right]) => left.localeCompare(right),
|
||||
)) {
|
||||
if (!location.includes("node_modules/") || locked.dev === true) continue;
|
||||
const packageDirectory = path.join(root, location);
|
||||
const details = JSON.parse(
|
||||
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
|
||||
);
|
||||
const candidates = (await readdir(packageDirectory))
|
||||
.filter((name) => /^(?:licen[cs]e|copying|notice)(?:\.|$)/iu.test(name))
|
||||
.sort();
|
||||
const texts = [];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
texts.push(
|
||||
`--- ${candidate} ---\n${await readFile(path.join(packageDirectory, candidate), "utf8")}`,
|
||||
);
|
||||
} catch {
|
||||
/* Ignore directories and non-text aliases. */
|
||||
}
|
||||
}
|
||||
sections.push(
|
||||
[
|
||||
"=".repeat(78),
|
||||
`${details.name}@${details.version}`,
|
||||
`Declared licence: ${details.license ?? locked.license ?? "See upstream"}`,
|
||||
`Installed from: ${location}`,
|
||||
"=".repeat(78),
|
||||
texts.join("\n\n") ||
|
||||
"No package-local licence file was present; see THIRD_PARTY_NOTICES.md.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
path.join(publicLicenses, "npm-runtime-licenses.txt"),
|
||||
`${sections.join("\n\n")}\n`,
|
||||
);
|
||||
console.log("Prepared static release notices");
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createServer } from "node:http";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"dist",
|
||||
);
|
||||
const nestedPrefix = "/deep/nested/auth/";
|
||||
const mediaTypes = new Map([
|
||||
[".css", "text/css; charset=utf-8"],
|
||||
[".html", "text/html; charset=utf-8"],
|
||||
[".js", "text/javascript; charset=utf-8"],
|
||||
[".json", "application/json; charset=utf-8"],
|
||||
[".svg", "image/svg+xml"],
|
||||
[".md", "text/markdown; charset=utf-8"],
|
||||
[".txt", "text/plain; charset=utf-8"],
|
||||
]);
|
||||
const headers = {
|
||||
"Content-Security-Policy":
|
||||
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'",
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
"Permissions-Policy": "camera=(), microphone=(), geolocation=(), usb=()",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
};
|
||||
|
||||
function safeFile(requestPath) {
|
||||
const decoded = decodeURIComponent(requestPath);
|
||||
const relative = decoded.startsWith(nestedPrefix)
|
||||
? decoded.slice(nestedPrefix.length)
|
||||
: decoded.replace(/^\/+/, "");
|
||||
const normalized = path.posix.normalize(relative || "index.html");
|
||||
if (
|
||||
normalized === ".." ||
|
||||
normalized.startsWith("../") ||
|
||||
path.isAbsolute(normalized)
|
||||
)
|
||||
return null;
|
||||
return path.join(root, normalized);
|
||||
}
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
||||
let file = safeFile(url.pathname);
|
||||
if (!file) {
|
||||
response.writeHead(400).end("Bad request");
|
||||
return;
|
||||
}
|
||||
if ((await stat(file).catch(() => null))?.isDirectory())
|
||||
file = path.join(file, "index.html");
|
||||
const content = await readFile(file);
|
||||
response.writeHead(200, {
|
||||
"Content-Type":
|
||||
mediaTypes.get(path.extname(file)) ?? "application/octet-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
...headers,
|
||||
});
|
||||
response.end(content);
|
||||
} catch {
|
||||
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
response.end("Not found");
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(4173, "127.0.0.1", () =>
|
||||
console.log(
|
||||
"Authentication Tools test server listening on http://127.0.0.1:4173",
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user