P6 complete: PWA, route motion, dex browser-flow OAuth, release pipeline
- PWA (§16): hand-rolled 60-line service worker (network-first pages with offline fallback, cache-first hashed statics, /api NEVER touched — SSE and approvals stay live), app manifest with §2 identity, stdlib- generated coral claw icons, prod-only registration. E2E asserts manifest, real PNG icons, an ACTIVATED service worker, and the /api bypass. (Serwist was tried and dropped: its webpack plugin fights Next 16's Turbopack builds; sixty lines we own beat a plugin we fight.) - Route motion (§3): (workspace) template re-mounts per navigation with a quiet fade-rise, zeroed under prefers-reduced-motion. The a11y sweep now settles running animations before scanning — axe was reading mid-fade opacity as contrast failures - OAuth browser flow vs REAL dex: the e2e harness boots dexidp/dex with static client + password; the journey drives the actual dex login form from /api/apps/oauth/start through the callback 303 and asserts the app reads connected (closing the P4 deferral honestly) - release.yml: tag-triggered — builds all four images + postgres, saves tarballs, assembles the SIGNED air-gapped bundle (compose, config, migrations, seccomp profile, installer, bundler binary), derives the public key via the new Could not find command "pubkey". subcommand (tested), verifies the bundle customer-style with the public half only, attaches tarball + public key to the GitHub release 153 Rust + 63 frontend tests + 29 Playwright journeys. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4f253bec93
commit
ace66d7ffb
@@ -0,0 +1,87 @@
|
|||||||
|
# Release: build the images both deploy targets share, assemble the
|
||||||
|
# SIGNED air-gapped bundle, verify it offline, and attach everything to
|
||||||
|
# the tag. The signing key lives in repo secrets (BUNDLE_SIGNING_KEY,
|
||||||
|
# hex ed25519 from `teamclaw-bundler keygen`); the matching public key is
|
||||||
|
# published out of band so customers can verify before docker load.
|
||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
bundle:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
- name: Version from tag
|
||||||
|
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Build images
|
||||||
|
run: |
|
||||||
|
docker build -t "teamclaw/server:$VERSION" -f images/server.Dockerfile .
|
||||||
|
docker build -t "teamclaw/frontend:$VERSION" -f images/frontend.Dockerfile .
|
||||||
|
docker build -t "teamclaw/agent-base:$VERSION" images/agent-base
|
||||||
|
docker build -t "teamclaw/agent-browser:$VERSION" images/agent-browser
|
||||||
|
docker pull postgres:16-alpine
|
||||||
|
|
||||||
|
- name: Save image tarballs
|
||||||
|
run: |
|
||||||
|
mkdir -p dist/images
|
||||||
|
docker save "teamclaw/server:$VERSION" -o dist/images/server.tar
|
||||||
|
docker save "teamclaw/frontend:$VERSION" -o dist/images/frontend.tar
|
||||||
|
docker save "teamclaw/agent-base:$VERSION" -o dist/images/agent-base.tar
|
||||||
|
docker save "teamclaw/agent-browser:$VERSION" -o dist/images/agent-browser.tar
|
||||||
|
docker save postgres:16-alpine -o dist/images/postgres.tar
|
||||||
|
|
||||||
|
- name: Build bundler
|
||||||
|
run: cargo build --release -p teamclaw-bundler
|
||||||
|
|
||||||
|
- name: Assemble and sign the bundle
|
||||||
|
env:
|
||||||
|
BUNDLE_SIGNING_KEY: ${{ secrets.BUNDLE_SIGNING_KEY }}
|
||||||
|
run: |
|
||||||
|
printf '%s' "$BUNDLE_SIGNING_KEY" > /tmp/release.key
|
||||||
|
BUNDLER=target/release/teamclaw-bundler
|
||||||
|
ARTIFACTS=""
|
||||||
|
for tar in dist/images/*.tar; do
|
||||||
|
ARTIFACTS="$ARTIFACTS $tar=images/$(basename "$tar")"
|
||||||
|
done
|
||||||
|
for migration in migrations/*.sql; do
|
||||||
|
ARTIFACTS="$ARTIFACTS $migration=migrations/$(basename "$migration")"
|
||||||
|
done
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
"$BUNDLER" assemble dist/bundle "$VERSION" /tmp/release.key \
|
||||||
|
deploy/compose/docker-compose.yml=compose/docker-compose.yml \
|
||||||
|
deploy/compose/teamclaw.toml=compose/teamclaw.toml \
|
||||||
|
deploy/compose/.env.example=compose/.env.example \
|
||||||
|
deploy/e2e/scenarios.toml=compose/scenarios.toml \
|
||||||
|
images/seccomp/agent-profile.json=seccomp/agent-profile.json \
|
||||||
|
deploy/airgapped/install.sh=install.sh \
|
||||||
|
"$BUNDLER"=bin/teamclaw-bundler \
|
||||||
|
$ARTIFACTS
|
||||||
|
chmod +x dist/bundle/bin/teamclaw-bundler dist/bundle/install.sh
|
||||||
|
rm /tmp/release.key
|
||||||
|
|
||||||
|
- name: Verify the bundle offline (public key only)
|
||||||
|
env:
|
||||||
|
BUNDLE_SIGNING_KEY: ${{ secrets.BUNDLE_SIGNING_KEY }}
|
||||||
|
run: |
|
||||||
|
printf '%s' "$BUNDLE_SIGNING_KEY" > /tmp/release.key
|
||||||
|
target/release/teamclaw-bundler pubkey /tmp/release.key dist/release.pub
|
||||||
|
rm /tmp/release.key
|
||||||
|
# The customer's exact procedure: only the public half.
|
||||||
|
target/release/teamclaw-bundler verify dist/bundle dist/release.pub
|
||||||
|
|
||||||
|
- name: Tarball
|
||||||
|
run: tar -C dist -czf "teamclaw-bundle-$VERSION.tgz" bundle
|
||||||
|
|
||||||
|
- name: Attach to release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
teamclaw-bundle-*.tgz
|
||||||
|
dist/release.pub
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Q2 revenue is up 14%.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Q2 revenue is up 14%.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Real OIDC IdP for the e2e OAuth browser-flow journey.
|
||||||
|
issuer: http://127.0.0.1:54556/dex
|
||||||
|
storage:
|
||||||
|
type: memory
|
||||||
|
web:
|
||||||
|
http: 0.0.0.0:5556
|
||||||
|
oauth2:
|
||||||
|
skipApprovalScreen: true
|
||||||
|
staticClients:
|
||||||
|
- id: teamclaw
|
||||||
|
secret: tc-e2e-oauth-secret
|
||||||
|
name: TeamClaw
|
||||||
|
redirectURIs:
|
||||||
|
- http://127.0.0.1:8080/api/apps/oauth/callback
|
||||||
|
enablePasswordDB: true
|
||||||
|
staticPasswords:
|
||||||
|
- email: [email protected]
|
||||||
|
# bcrypt("password") — dex's documented example hash.
|
||||||
|
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
|
||||||
|
username: admin
|
||||||
|
userID: 08a8684b-db88-4b73-90a9-3cd1661f5466
|
||||||
@@ -20,3 +20,9 @@ socket_path = "/tmp/teamclaw-e2e-broker.sock"
|
|||||||
|
|
||||||
[slack]
|
[slack]
|
||||||
base_url = "http://127.0.0.1:8080/__slack"
|
base_url = "http://127.0.0.1:8080/__slack"
|
||||||
|
|
||||||
|
[oauth]
|
||||||
|
issuer_url = "http://127.0.0.1:54556/dex"
|
||||||
|
client_id = "teamclaw"
|
||||||
|
client_secret = "tc-e2e-oauth-secret"
|
||||||
|
redirect_base = "http://127.0.0.1:8080"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 741 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,60 @@
|
|||||||
|
// TeamClaw service worker (§16 PWA). Hand-rolled on purpose: ~50 lines we
|
||||||
|
// fully control beats a build-tool plugin. Strategy:
|
||||||
|
// /api/** -> NEVER touched (approvals + SSE must be live)
|
||||||
|
// /_next/static/** -> cache-first (content-hashed, immutable)
|
||||||
|
// navigations -> network-first, cache fallback for offline shell
|
||||||
|
const STATIC_CACHE = "tc-static-v1";
|
||||||
|
const PAGE_CACHE = "tc-pages-v1";
|
||||||
|
|
||||||
|
self.addEventListener("install", () => {
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const keep = [STATIC_CACHE, PAGE_CACHE];
|
||||||
|
for (const key of await caches.keys()) {
|
||||||
|
if (!keep.includes(key)) await caches.delete(key);
|
||||||
|
}
|
||||||
|
await self.clients.claim();
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
if (url.pathname.startsWith("/api/")) return;
|
||||||
|
|
||||||
|
if (url.pathname.startsWith("/_next/static/")) {
|
||||||
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(STATIC_CACHE);
|
||||||
|
const hit = await cache.match(event.request);
|
||||||
|
if (hit) return hit;
|
||||||
|
const response = await fetch(event.request);
|
||||||
|
if (response.ok) cache.put(event.request, response.clone());
|
||||||
|
return response;
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.request.mode === "navigate") {
|
||||||
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(PAGE_CACHE);
|
||||||
|
try {
|
||||||
|
const response = await fetch(event.request);
|
||||||
|
if (response.ok) cache.put(event.request, response.clone());
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
const hit = await cache.match(event.request);
|
||||||
|
if (hit) return hit;
|
||||||
|
throw new Error("offline and not cached");
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// Re-mounts on every route change inside the workspace, giving each page
|
||||||
|
// the §3 enter motion. prefers-reduced-motion zeroes it via motion.css.
|
||||||
|
export default function WorkspaceTemplate({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="h-full motion-safe:animate-[route-enter_var(--duration-normal)_var(--ease-app)]">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||||
|
|
||||||
|
import { RegisterServiceWorker } from "@/components/shell/RegisterServiceWorker";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
// Vendored variable fonts (src/fonts) — zero external requests, so the same
|
// Vendored variable fonts (src/fonts) — zero external requests, so the same
|
||||||
@@ -29,6 +31,7 @@ export default function RootLayout({
|
|||||||
<html lang="en" className={`${geist.variable} ${geistMono.variable} h-full`}>
|
<html lang="en" className={`${geist.variable} ${geistMono.variable} h-full`}>
|
||||||
<body className="min-h-full antialiased">
|
<body className="min-h-full antialiased">
|
||||||
<NuqsAdapter>{children}</NuqsAdapter>
|
<NuqsAdapter>{children}</NuqsAdapter>
|
||||||
|
<RegisterServiceWorker />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
// §16 NFR: installable PWA. Icons are generated at build time into
|
||||||
|
// /public/icons (script: npm run icons).
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
return {
|
||||||
|
name: "TeamClaw",
|
||||||
|
short_name: "TeamClaw",
|
||||||
|
description: "Collaborative AI agents with human-in-the-loop safety.",
|
||||||
|
start_url: "/",
|
||||||
|
display: "standalone",
|
||||||
|
background_color: "#121212",
|
||||||
|
theme_color: "#f96565",
|
||||||
|
icons: [
|
||||||
|
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
|
||||||
|
{
|
||||||
|
src: "/icons/icon-512.png",
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/png",
|
||||||
|
purpose: "maskable",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
/** Registers the PWA service worker (prod builds only — dev reloads and
|
||||||
|
* SW caching fight each other). */
|
||||||
|
export function RegisterServiceWorker() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
process.env.NODE_ENV === "production" &&
|
||||||
|
"serviceWorker" in navigator
|
||||||
|
) {
|
||||||
|
navigator.serviceWorker.register("/sw.js").catch(() => {
|
||||||
|
// Browsers without SW support (or private modes) degrade fine.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -95,6 +95,18 @@
|
|||||||
0%, 100% { transform: translateY(0); }
|
0%, 100% { transform: translateY(0); }
|
||||||
50% { transform: translateY(-6px); }
|
50% { transform: translateY(-6px); }
|
||||||
}
|
}
|
||||||
|
/* Route changes inside the workspace (§3): a quiet rise, nothing showy. */
|
||||||
|
@keyframes route-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes tip-slide {
|
@keyframes tip-slide {
|
||||||
from { transform: translateY(8px); opacity: 0; }
|
from { transform: translateY(8px); opacity: 0; }
|
||||||
to { transform: translateY(0); opacity: 1; }
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ async function signIn(page: Page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function expectClean(page: Page, context: string) {
|
async function expectClean(page: Page, context: string) {
|
||||||
|
// Route-enter fades opacity; scanning mid-animation reads false
|
||||||
|
// contrast failures. Let running animations settle first.
|
||||||
|
await page.evaluate(() =>
|
||||||
|
Promise.allSettled(document.getAnimations().map((a) => a.finished)),
|
||||||
|
);
|
||||||
const results = await new AxeBuilder({ page }).analyze();
|
const results = await new AxeBuilder({ page }).analyze();
|
||||||
const blocking = results.violations.filter(
|
const blocking = results.violations.filter(
|
||||||
(v) => v.impact === "serious" || v.impact === "critical",
|
(v) => v.impact === "serious" || v.impact === "critical",
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
// P6: the OAuth connect round trip driven through a REAL browser against
|
||||||
|
// a REAL dex IdP — authorize redirect, dex's own login form, code
|
||||||
|
// exchange at dex's token endpoint, broker-held token, connected app.
|
||||||
|
|
||||||
|
const OWNER_EMAIL = "[email protected]";
|
||||||
|
const OWNER_PASSWORD = "e2e-password";
|
||||||
|
const BACKEND = "http://127.0.0.1:8080";
|
||||||
|
|
||||||
|
test("connecting an app via OAuth walks the real dex login", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
// API session for start + verification.
|
||||||
|
const login = await request.post(`${BACKEND}/api/auth/login`, {
|
||||||
|
data: { email: OWNER_EMAIL, password: OWNER_PASSWORD },
|
||||||
|
});
|
||||||
|
const { token } = (await login.json()) as { token: string };
|
||||||
|
const auth = { Authorization: `Bearer ${token}` };
|
||||||
|
const claws = (await (
|
||||||
|
await request.get(`${BACKEND}/api/team/claws`, { headers: auth })
|
||||||
|
).json()) as { id: string; name: string }[];
|
||||||
|
const scout = claws.find((claw) => claw.name === "Scout")!;
|
||||||
|
|
||||||
|
const start = await request.post(`${BACKEND}/api/apps/oauth/start`, {
|
||||||
|
headers: auth,
|
||||||
|
data: { clawId: scout.id, provider: "linear" },
|
||||||
|
});
|
||||||
|
expect(start.status()).toBe(200);
|
||||||
|
const { authorize_url } = (await start.json()) as { authorize_url: string };
|
||||||
|
expect(authorize_url).toContain("/dex/auth");
|
||||||
|
|
||||||
|
// The REAL browser flow: dex serves its login form; sign in as the
|
||||||
|
// static user; dex redirects back through our callback.
|
||||||
|
await page.goto(authorize_url);
|
||||||
|
await page.getByPlaceholder("email address").fill("[email protected]");
|
||||||
|
await page.getByPlaceholder("password").fill("password");
|
||||||
|
await page.getByRole("button", { name: /Log ?in/i }).click();
|
||||||
|
|
||||||
|
// The callback 303s into the claw's Add Apps panel.
|
||||||
|
await page.waitForURL(/app=apps/);
|
||||||
|
|
||||||
|
// The connection exists and the app reads connected.
|
||||||
|
const directory = (await (
|
||||||
|
await request.get(`${BACKEND}/api/apps?clawId=${scout.id}`, {
|
||||||
|
headers: auth,
|
||||||
|
})
|
||||||
|
).json()) as { id: string; connected: boolean }[];
|
||||||
|
expect(directory.find((app) => app.id === "linear")?.connected).toBe(true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
// P6 exit (§16 NFR): installable PWA — manifest, icons, and a live
|
||||||
|
// service worker that never touches /api.
|
||||||
|
|
||||||
|
test("the app is installable: manifest, icons, service worker", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
|
||||||
|
// Manifest is linked and serves the §2 identity.
|
||||||
|
const href = await page
|
||||||
|
.locator('link[rel="manifest"]')
|
||||||
|
.getAttribute("href");
|
||||||
|
expect(href).toBeTruthy();
|
||||||
|
const manifest = await (await page.request.get(href!)).json();
|
||||||
|
expect(manifest.name).toBe("TeamClaw");
|
||||||
|
expect(manifest.display).toBe("standalone");
|
||||||
|
expect(manifest.theme_color).toBe("#f96565");
|
||||||
|
|
||||||
|
// Icons are real PNGs.
|
||||||
|
for (const icon of manifest.icons) {
|
||||||
|
const res = await page.request.get(icon.src);
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
expect((await res.body()).subarray(0, 4).toString("latin1")).toContain(
|
||||||
|
"PNG",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The service worker registers and activates (prod build in e2e).
|
||||||
|
const state = await page.evaluate(async () => {
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
return registration.active?.state;
|
||||||
|
});
|
||||||
|
expect(state).toBe("activated");
|
||||||
|
|
||||||
|
// The SW intercepts static assets but NEVER /api (live SSE).
|
||||||
|
const swText = await (await page.request.get("/sw.js")).text();
|
||||||
|
expect(swText).toContain('url.pathname.startsWith("/api/")');
|
||||||
|
});
|
||||||
@@ -17,6 +17,16 @@ until docker exec teamclaw-e2e-pg pg_isready -U postgres -q >/dev/null 2>&1; do
|
|||||||
sleep 0.5
|
sleep 0.5
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Real OIDC IdP for the OAuth browser-flow journey.
|
||||||
|
docker rm -f teamclaw-e2e-dex >/dev/null 2>&1 || true
|
||||||
|
docker run -d --name teamclaw-e2e-dex \
|
||||||
|
-p 54556:5556 \
|
||||||
|
-v "$ROOT/deploy/e2e/dex.yaml:/etc/dex/config.yaml:ro" \
|
||||||
|
dexidp/dex:v2.41.1 dex serve /etc/dex/config.yaml >/dev/null
|
||||||
|
until curl -fsS http://127.0.0.1:54556/dex/.well-known/openid-configuration >/dev/null 2>&1; do
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
export TEAMCLAW_MODE=e2e
|
export TEAMCLAW_MODE=e2e
|
||||||
export TEAMCLAW_CONFIG="$ROOT/deploy/e2e/teamclaw.e2e.toml"
|
export TEAMCLAW_CONFIG="$ROOT/deploy/e2e/teamclaw.e2e.toml"
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ pub fn generate_keypair(private_path: &Path, public_path: &Path) -> Result<(), B
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-derives the public key from a signing key (release pipelines store
|
||||||
|
/// only the private half in secrets).
|
||||||
|
pub fn derive_public_key(private_path: &Path, public_path: &Path) -> Result<(), BundleError> {
|
||||||
|
let signing = load_signing_key(private_path)?;
|
||||||
|
fs::write(public_path, hex::encode(signing.verifying_key().to_bytes())).map_err(io_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn load_signing_key(path: &Path) -> Result<SigningKey, BundleError> {
|
fn load_signing_key(path: &Path) -> Result<SigningKey, BundleError> {
|
||||||
let bytes: [u8; 32] = hex::decode(fs::read_to_string(path).map_err(io_err)?.trim())
|
let bytes: [u8; 32] = hex::decode(fs::read_to_string(path).map_err(io_err)?.trim())
|
||||||
.map_err(|e| BundleError::Key(e.to_string()))?
|
.map_err(|e| BundleError::Key(e.to_string()))?
|
||||||
|
|||||||
@@ -52,12 +52,17 @@ fn run() -> Result<String, String> {
|
|||||||
args[1]
|
args[1]
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
Some("pubkey") if args.len() == 3 => {
|
||||||
|
teamclaw_bundler::derive_public_key(Path::new(&args[1]), Path::new(&args[2]))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(format!("wrote {}", args[2]))
|
||||||
|
}
|
||||||
Some("verify") if args.len() == 3 => {
|
Some("verify") if args.len() == 3 => {
|
||||||
let verified =
|
let verified =
|
||||||
teamclaw_bundler::verify(Path::new(&args[1]), Path::new(&args[2]))
|
teamclaw_bundler::verify(Path::new(&args[1]), Path::new(&args[2]))
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(format!("bundle OK: {verified} artifacts verified offline"))
|
Ok(format!("bundle OK: {verified} artifacts verified offline"))
|
||||||
}
|
}
|
||||||
_ => Err("usage: keygen <priv> <pub> | assemble <out> <version> <priv> <src=dest>... | verify <bundle> <pub>".into()),
|
_ => Err("usage: keygen <priv> <pub> | pubkey <priv> <pub> | assemble <out> <version> <priv> <src=dest>... | verify <bundle> <pub>".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,3 +98,16 @@ fn a_missing_artifact_is_reported() {
|
|||||||
let err = verify(&fx.bundle, &fx.public_key).unwrap_err();
|
let err = verify(&fx.bundle, &fx.public_key).unwrap_err();
|
||||||
assert!(matches!(err, BundleError::Missing(p) if p.contains("0001_init.sql")));
|
assert!(matches!(err, BundleError::Missing(p) if p.contains("0001_init.sql")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_public_key_rederives_from_the_signing_key() {
|
||||||
|
let fx = fixture();
|
||||||
|
let derived = fx.root.join("derived.pub");
|
||||||
|
teamclaw_bundler::derive_public_key(&fx.root.join("release.key"), &derived).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(&derived).unwrap(),
|
||||||
|
fs::read_to_string(&fx.public_key).unwrap()
|
||||||
|
);
|
||||||
|
// And it verifies the bundle, same as the original.
|
||||||
|
assert_eq!(verify(&fx.bundle, &derived).unwrap(), 4);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user