kern0-cli 0.1.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 ADDED
@@ -0,0 +1,110 @@
1
+ # Kern0 — Enterprise Recovery Control Plane
2
+
3
+ Next.js 16 + Firebase (Firestore + Auth) control plane for identity
4
+ resilience. Multi-customer, multi-environment monitoring with a guided
5
+ onboarding flow, node pre-approval and a full audit log.
6
+
7
+ Migrated from the single-page Rust consoles (`C:\src\r1_sidecar` UI +
8
+ `C:\src\sending` architecture). See `docs/ARCHITECTURE.md` for the mapping and
9
+ `docs/FIREBASE.md` for the Firestore collection map + console checklist.
10
+
11
+ ## What’s in milestone 1
12
+
13
+ - **Google sign-in** (Firebase Auth). The first account on an empty platform
14
+ becomes **Global Admin**; later self-service sign-ups default to
15
+ **Read Only** until an admin grants more. Roles today: `global_admin`,
16
+ `full_admin`, `read_only`.
17
+ - **Guided onboarding** — create a customer by **DNS domain** + **forest
18
+ GUID(s)**, one environment per forest, optionally pre-approve the first node.
19
+ - **Environments + switcher** — monitor many ADFR/DSP environments at once and
20
+ switch instantly (top-bar selector; remembered per operator).
21
+ - **Nodes & Pre-approval** — pre-approve iroh nodes with label + approved
22
+ forests (blank = unrestricted), enable/disable, edit forests, remove —
23
+ mirroring the r1 admin console.
24
+ - **Audit log** — every privileged action is recorded (metadata only, never
25
+ secret values).
26
+ - Server-authorised API (httpOnly session cookie). The browser never talks to
27
+ Firestore directly.
28
+
29
+ ## Get started
30
+
31
+ ```bash
32
+ npm install
33
+ cp .env.example .env.local # fill in service account (see below)
34
+ npm run firebase:init # materialise settings + audit (idempotent)
35
+ npm run dev # http://localhost:3000
36
+ ```
37
+
38
+ `.env.local` is git-ignored. The Firebase Admin SDK needs a service account:
39
+
40
+ 1. **Option A (dev):** keep
41
+ `kern0-8aed1-firebase-adminsdk-fbsvc-11bf7104e0.json` in the repo root
42
+ (already `.gitignore`d) — it is picked up automatically.
43
+ 2. **Option B (env):** set `FIREBASE_SERVICE_ACCOUNT_BASE64` (or
44
+ `FIREBASE_SERVICE_ACCOUNT_PATH` / `GOOGLE_APPLICATION_CREDENTIALS`).
45
+ 3. The client config (`NEXT_PUBLIC_FIREBASE_*`) is **public** — defaults for
46
+ the `kern0-8aed1` project are already in `lib/client/firebase.ts`.
47
+ 4. Set `KERN0_PSK_ENCRYPTION_KEY` (64 hex chars) in `.env.local` — **required
48
+ for passkeys**: the second-factor login cookie and destructive-action
49
+ step-up tokens are signed with it. Use the shared team value (same as
50
+ production) if you touch PSK-encrypted data; `openssl rand -hex 32` is
51
+ fine for UI-only dev. Restart `npm run dev` after changing it.
52
+
53
+ > Security: the service-account JSON must never be committed. It is covered by
54
+ > `.gitignore` (`*-firebase-adminsdk-*.json`, `*service-account*.json`, etc.)
55
+ > and `git status` is verified before every push.
56
+
57
+ ## Staging environment
58
+
59
+ A second App Hosting backend (`staging`, us-east4, same project
60
+ `kern0-8aed1`) serves the `staging` git branch for pre-merge testing:
61
+
62
+ - **URLs:** https://staging.kernzero.com (custom domain) and
63
+ https://staging--kern0-8aed1.us-east4.hosted.app (default).
64
+ - **Flow:** push to the `staging` branch → App Hosting auto-builds and rolls
65
+ out. Prod (`kern0` backend, https://kernzero.com) deploys only from
66
+ `master`. Merge `master` into `staging` regularly so it stays current.
67
+ - **Passkeys:** staging uses the same rp_id as prod (`kernzero.com`) via
68
+ backend env overrides, so passkeys enrolled on prod work on staging without
69
+ re-enrollment. Overrides in play: `KERN0_WEBAUTHN_RP_ID=kernzero.com`,
70
+ `KERN0_WEBAUTHN_ORIGIN=https://staging.kernzero.com,https://staging--kern0-8aed1.us-east4.hosted.app`.
71
+ - **Env-change gotcha:** App Hosting bakes env vars into the build artifact —
72
+ changing backend env overrides requires a **new build + rollout**;
73
+ redeploying an existing build keeps the old values.
74
+ - **Shared data warning:** staging shares prod's Firestore, Auth users, and
75
+ secrets (same Firebase project). Destructive actions on staging delete real
76
+ data — passkey step-up gates apply, but test with care.
77
+ - **DNS:** `staging.kernzero.com` needs an A record (`35.219.200.193`) and a
78
+ Firebase ownership TXT record in the kernzero.com zone (GoDaddy).
79
+
80
+ Console entry points (read-only roles `roles/logging.viewer` +
81
+ `roles/firebase.viewer` cover logs and backend visibility for both backends):
82
+
83
+ - Logs: https://console.cloud.google.com/logs/query?project=kern0-8aed1
84
+ (filter Cloud Run service `kern0` = prod, `staging` = staging;
85
+ `logName="projects/kern0-8aed1/logs/cloudbuild"` for build logs)
86
+ - App Hosting: https://console.firebase.google.com/project/kern0-8aed1/apphosting
87
+
88
+ ## Scripts
89
+
90
+ ```bash
91
+ npm run dev # next dev (Turbopack)
92
+ npm run build # next build
93
+ npm run lint # eslint
94
+ npm run typecheck # tsc --noEmit
95
+ npm run firebase:init# Firestore bootstrap
96
+ ```
97
+
98
+ ## Layout
99
+
100
+ ```
101
+ app/ # App Router: pages + /api route handlers
102
+ app/api/… # session, me, customers, onboard, environments, nodes, audit
103
+ components/ # auth provider, app shell, wizard, UI primitives
104
+ lib/firebase/admin.ts # Firebase Admin init (server)
105
+ lib/server/* # auth guards, audit, users, onboarding, serializers
106
+ lib/client/* # Firebase web + fetch helper
107
+ lib/validation.ts # forest GUID / domain / node-id rules (mirrors Rust)
108
+ scripts/bootstrap.mjs # Firestore bootstrap
109
+ docs/ # MIGRATION.md (product/migration plan), FIREBASE.md, ARCHITECTURE.md
110
+ ```
package/README.npm.md ADDED
@@ -0,0 +1,137 @@
1
+ # KernZero CLI
2
+
3
+ Command-line interface for managing recovery control planes and monitoring environments.
4
+
5
+ ## Quick Start
6
+
7
+ ### Install
8
+
9
+ ```bash
10
+ npm install -g @kernzero/cli
11
+ ```
12
+
13
+ ### Authenticate
14
+
15
+ ```bash
16
+ kern0 login
17
+ ```
18
+
19
+ This opens your browser for device flow authentication with passkeys. No passwords needed.
20
+
21
+ ### Use
22
+
23
+ ```bash
24
+ # Check who you are
25
+ kern0 whoami
26
+
27
+ # List organizations
28
+ kern0 org list
29
+
30
+ # Select an organization
31
+ kern0 org select <org-id>
32
+
33
+ # View environments
34
+ kern0 environments list
35
+
36
+ # Manage tokens
37
+ kern0 tokens list
38
+ kern0 tokens create "my-token"
39
+ ```
40
+
41
+ ## Features
42
+
43
+ ✅ **Device Flow Authentication** - Secure OAuth 2.0 with passkeys
44
+ ✅ **Multi-tenant Support** - Manage multiple organizations and environments
45
+ ✅ **Token Management** - Create and revoke API tokens
46
+ ✅ **Environment Monitoring** - View status across all monitored systems
47
+ ✅ **Zero Trust** - Device-bound authentication
48
+ ✅ **MCP Integration** - Connect to AI assistants via Model Context Protocol
49
+
50
+ ## Documentation
51
+
52
+ Full documentation available at: https://github.com/santorres/kern0/blob/main/docs/CLI.md
53
+
54
+ ## Commands
55
+
56
+ ### Authentication
57
+ ```bash
58
+ kern0 login # Authenticate with device flow
59
+ kern0 logout # Logout and clear token
60
+ kern0 whoami # View current context
61
+ ```
62
+
63
+ ### Organizations
64
+ ```bash
65
+ kern0 org list # List accessible organizations
66
+ kern0 org select <id> # Select an organization
67
+ kern0 org info # View organization details
68
+ kern0 org create <name> # Create new organization
69
+ ```
70
+
71
+ ### Environments
72
+ ```bash
73
+ kern0 environments list # List all environments
74
+ kern0 environments info <id> # View environment details
75
+ ```
76
+
77
+ ### Tokens
78
+ ```bash
79
+ kern0 tokens list # List API tokens
80
+ kern0 tokens create <name> # Create new token
81
+ kern0 tokens revoke <id> # Revoke a token
82
+ ```
83
+
84
+ ### MCP Server
85
+ ```bash
86
+ kern0-mcp # Start MCP server for AI integration
87
+ ```
88
+
89
+ ## Environment Variables
90
+
91
+ ```bash
92
+ # Required
93
+ KERN0_API_URL=https://staging.kernzero.com
94
+
95
+ # Optional (auto-set after login)
96
+ KERN0_TOKEN=...
97
+ ```
98
+
99
+ ## Troubleshooting
100
+
101
+ **"Command not found"**
102
+ ```bash
103
+ # Ensure npm global bin is in PATH
104
+ npm config get prefix
105
+ export PATH="$(npm config get prefix)/bin:$PATH"
106
+ ```
107
+
108
+ **"Not authenticated"**
109
+ ```bash
110
+ # Re-authenticate
111
+ kern0 login
112
+ ```
113
+
114
+ **"User does not have access to this organization"**
115
+ ```bash
116
+ # View available organizations
117
+ kern0 org list
118
+
119
+ # Use a valid org ID
120
+ kern0 org select <valid-id>
121
+ ```
122
+
123
+ ## Support
124
+
125
+ For issues or questions:
126
+ 1. Check `kern0 --help`
127
+ 2. View logs: `~/.kern0/logs`
128
+ 3. GitHub Issues: https://github.com/santorres/kern0/issues
129
+
130
+ ## License
131
+
132
+ MIT
133
+
134
+ ---
135
+
136
+ **Version:** 0.1.0
137
+ **Node.js:** 18+
package/bin/kern0 ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * KernZero CLI - Entry Point
4
+ * Dynamically imports and runs the CLI
5
+ */
6
+
7
+ import { execSync } from 'child_process';
8
+ import path from 'path';
9
+ import { fileURLToPath } from 'url';
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const projectRoot = path.resolve(__dirname, '..');
13
+
14
+ try {
15
+ // Use tsx to run TypeScript CLI
16
+ const args = process.argv.slice(2);
17
+ execSync(`npx tsx ${path.join(projectRoot, 'lib/cli/index.ts')} ${args.join(' ')}`, {
18
+ stdio: 'inherit',
19
+ cwd: projectRoot
20
+ });
21
+ } catch (error) {
22
+ process.exit(1);
23
+ }
package/bin/kern0-mcp ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "../lib/mcp/server.js";
package/lib/cli/api.ts ADDED
@@ -0,0 +1,208 @@
1
+ import { loadToken, loadConfig } from "./config";
2
+
3
+ export interface ApiErrorResponse {
4
+ error: string;
5
+ success: false;
6
+ timestamp: number;
7
+ }
8
+
9
+ export interface ApiSuccessResponse<T> {
10
+ data?: T;
11
+ success: true;
12
+ timestamp: number;
13
+ }
14
+
15
+ export type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;
16
+
17
+ export async function apiCall<T>(
18
+ method: "GET" | "POST" | "PUT" | "DELETE",
19
+ endpoint: string,
20
+ body?: unknown,
21
+ ): Promise<T> {
22
+ const config = loadConfig();
23
+ const token = loadToken();
24
+
25
+ if (!token) {
26
+ throw new Error("Not authenticated. Run `kern0 login` first.");
27
+ }
28
+
29
+ const url = new URL(endpoint, config.api_url).href;
30
+ const headers: Record<string, string> = {
31
+ "Authorization": `Bearer ${token}`,
32
+ "Content-Type": "application/json",
33
+ };
34
+
35
+ const options: RequestInit = {
36
+ method,
37
+ headers,
38
+ };
39
+
40
+ if (body) {
41
+ options.body = JSON.stringify(body);
42
+ }
43
+
44
+ try {
45
+ const response = await fetch(url, options);
46
+ const data = (await response.json()) as ApiResponse<T>;
47
+
48
+ if (!data.success) {
49
+ const errorMsg = (data as ApiErrorResponse).error || "API error";
50
+ throw new Error(errorMsg);
51
+ }
52
+
53
+ return ((data as ApiSuccessResponse<T>).data || {}) as T;
54
+ } catch (error) {
55
+ if (error instanceof Error) {
56
+ throw error;
57
+ }
58
+ throw new Error(`API call failed: ${error}`);
59
+ }
60
+ }
61
+
62
+ // Organization API
63
+
64
+ export interface Organization {
65
+ id: string;
66
+ name: string;
67
+ type: "direct" | "msp";
68
+ createdAt: number;
69
+ }
70
+
71
+ export async function listOrganizations(): Promise<Organization[]> {
72
+ const response = await apiCall<{ organizations: Organization[] }>(
73
+ "GET",
74
+ "/api/v1/orgs",
75
+ );
76
+ return response.organizations || [];
77
+ }
78
+
79
+ export async function getOrganization(orgId: string): Promise<Organization> {
80
+ const response = await apiCall<{ organization: Organization }>("GET", `/api/v1/orgs/${orgId}`);
81
+ return response.organization;
82
+ }
83
+
84
+ export async function createOrganization(
85
+ name: string,
86
+ type: "direct" | "msp",
87
+ ): Promise<Organization> {
88
+ const response = await apiCall<{ organization: Organization }>("POST", "/api/v1/orgs", { name, type });
89
+ return response.organization;
90
+ }
91
+
92
+ // Members API
93
+
94
+ export interface Member {
95
+ userId: string;
96
+ email: string;
97
+ role: "org:admin" | "org:member";
98
+ customerIds: string[];
99
+ grantedAt: number;
100
+ }
101
+
102
+ export async function listMembers(orgId: string): Promise<Member[]> {
103
+ const response = await apiCall<{ members: Member[] }>(
104
+ "GET",
105
+ `/api/v1/orgs/${orgId}/members`,
106
+ );
107
+ return response.members || [];
108
+ }
109
+
110
+ export async function addMember(
111
+ orgId: string,
112
+ userId: string,
113
+ role: "org:admin" | "org:member",
114
+ customerIds?: string[],
115
+ ): Promise<Member> {
116
+ return apiCall<Member>("POST", `/api/v1/orgs/${orgId}/members`, {
117
+ userId,
118
+ role,
119
+ customerIds,
120
+ });
121
+ }
122
+
123
+ // Customers API
124
+
125
+ export interface Customer {
126
+ id: string;
127
+ name: string;
128
+ organizationId: string;
129
+ createdAt: number;
130
+ }
131
+
132
+ export async function listCustomers(orgId: string): Promise<Customer[]> {
133
+ const response = await apiCall<{ customers: Customer[] }>(
134
+ "GET",
135
+ `/api/v1/orgs/${orgId}/customers`,
136
+ );
137
+ return response.customers || [];
138
+ }
139
+
140
+ // Tokens API
141
+
142
+ export interface Token {
143
+ id: string;
144
+ name: string;
145
+ scopes: string[];
146
+ createdAt: number;
147
+ expiresAt: number;
148
+ lastUsedAt?: number;
149
+ revokedAt?: number;
150
+ }
151
+
152
+ export interface TokenResponse extends Token {
153
+ token: string; // Only returned on creation
154
+ }
155
+
156
+ export async function listTokens(orgId: string): Promise<Token[]> {
157
+ const response = await apiCall<{ tokens: Token[] }>(
158
+ "GET",
159
+ `/api/v1/orgs/${orgId}/tokens`,
160
+ );
161
+ return response.tokens || [];
162
+ }
163
+
164
+ export async function createToken(
165
+ orgId: string,
166
+ name: string,
167
+ scopes?: string[],
168
+ customerIds?: string[],
169
+ expiresIn?: number,
170
+ ): Promise<TokenResponse> {
171
+ return apiCall<TokenResponse>("POST", `/api/v1/orgs/${orgId}/tokens`, {
172
+ name,
173
+ scopes,
174
+ customerIds,
175
+ expiresIn,
176
+ });
177
+ }
178
+
179
+ export async function revokeToken(orgId: string, tokenId: string): Promise<void> {
180
+ await apiCall("DELETE", `/api/v1/orgs/${orgId}/tokens/${tokenId}`);
181
+ }
182
+
183
+ // Audit API
184
+
185
+ export interface AuditLog {
186
+ id: string;
187
+ timestamp: number;
188
+ userId: string;
189
+ action: string;
190
+ resource: string;
191
+ method: string;
192
+ status: number;
193
+ ipAddress?: string;
194
+ }
195
+
196
+ export async function listAuditLogs(
197
+ orgId: string,
198
+ limit?: number,
199
+ ): Promise<AuditLog[]> {
200
+ const params = new URLSearchParams();
201
+ if (limit) params.append("limit", limit.toString());
202
+
203
+ const response = await apiCall<{ logs: AuditLog[] }>(
204
+ "GET",
205
+ `/api/v1/orgs/${orgId}/audit-logs?${params.toString()}`,
206
+ );
207
+ return response.logs || [];
208
+ }
@@ -0,0 +1,155 @@
1
+ import { loadConfig, saveToken, setAuth, setUser, setOrganizations } from "./config";
2
+
3
+ export interface DeviceCodeResponse {
4
+ device_code: string;
5
+ user_code: string;
6
+ verification_uri: string;
7
+ expires_in: number;
8
+ }
9
+
10
+ export interface DeviceTokenResponse {
11
+ access_token: string;
12
+ token_type: string;
13
+ expires_in: number;
14
+ user?: { id: string; email: string };
15
+ }
16
+
17
+ export interface UserInfo {
18
+ id: string;
19
+ email: string;
20
+ }
21
+
22
+ const POLL_INTERVAL = 2000; // 2 seconds
23
+ const POLL_TIMEOUT = 600000; // 10 minutes (600 seconds from device code)
24
+
25
+ export async function initializeDeviceFlow(): Promise<DeviceCodeResponse> {
26
+ const config = loadConfig();
27
+
28
+ const response = await fetch(
29
+ new URL("/api/v1/auth/device-code", config.api_url).href,
30
+ {
31
+ method: "POST",
32
+ headers: { "Content-Type": "application/json" },
33
+ },
34
+ );
35
+
36
+ if (!response.ok) {
37
+ throw new Error("Failed to initialize device flow");
38
+ }
39
+
40
+ return response.json() as Promise<DeviceCodeResponse>;
41
+ }
42
+
43
+ export async function pollForToken(
44
+ deviceCode: string,
45
+ onProgress?: (message: string) => void,
46
+ ): Promise<DeviceTokenResponse> {
47
+ const config = loadConfig();
48
+ const startTime = Date.now();
49
+ const timeout = POLL_TIMEOUT;
50
+
51
+ return new Promise((resolve, reject) => {
52
+ const pollInterval = setInterval(async () => {
53
+ const elapsed = Date.now() - startTime;
54
+
55
+ if (elapsed > timeout) {
56
+ clearInterval(pollInterval);
57
+ reject(new Error("Device flow timeout - approval expired"));
58
+ return;
59
+ }
60
+
61
+ try {
62
+ const response = await fetch(
63
+ new URL("/api/v1/auth/device-token", config.api_url).href,
64
+ {
65
+ method: "POST",
66
+ headers: { "Content-Type": "application/json" },
67
+ body: JSON.stringify({ device_code: deviceCode }),
68
+ },
69
+ );
70
+
71
+ if (!response.ok) {
72
+ if (response.status === 400) {
73
+ // Authorization pending, keep polling
74
+ return;
75
+ }
76
+ throw new Error("Failed to exchange device code");
77
+ }
78
+
79
+ const data = (await response.json()) as DeviceTokenResponse;
80
+ clearInterval(pollInterval);
81
+ resolve(data);
82
+ } catch (error) {
83
+ if (error instanceof Error && error.message.includes("Failed")) {
84
+ clearInterval(pollInterval);
85
+ reject(error);
86
+ }
87
+ // Other errors (network, etc) - keep polling
88
+ }
89
+ }, POLL_INTERVAL);
90
+ });
91
+ }
92
+
93
+ export async function getUserInfo(token: string): Promise<UserInfo> {
94
+ // For CLI, derive user info from token data
95
+ // In production, this would call /api/me with token auth
96
+ // For now, return placeholder - will be set from API responses
97
+ return {
98
+ id: "cli-user",
99
+ email: "user@example.com",
100
+ };
101
+ }
102
+
103
+ export async function getOrganizations(token: string): Promise<
104
+ Array<{ id: string; name: string }>
105
+ > {
106
+ const config = loadConfig();
107
+
108
+ const response = await fetch(
109
+ new URL("/api/v1/orgs", config.api_url).href,
110
+ {
111
+ headers: { Authorization: `Bearer ${token}` },
112
+ },
113
+ );
114
+
115
+ if (!response.ok) {
116
+ throw new Error("Failed to fetch organizations");
117
+ }
118
+
119
+ const data = await response.json() as any;
120
+ return (data.organizations || []).map((org: any) => ({
121
+ id: org.id,
122
+ name: org.name,
123
+ }));
124
+ }
125
+
126
+ export async function completeLogin(
127
+ token: string,
128
+ expiresIn: number,
129
+ userFromToken?: { id: string; email: string },
130
+ ): Promise<{ user: UserInfo; organizations: Array<{ id: string; name: string }> }> {
131
+ // Get user info (from token if available, otherwise fallback)
132
+ const user = userFromToken ? { id: userFromToken.id, email: userFromToken.email } : await getUserInfo(token);
133
+
134
+ // Get organizations
135
+ const organizations = await getOrganizations(token);
136
+
137
+ // Save credentials
138
+ saveToken(token);
139
+
140
+ // Save auth state
141
+ const expiresAt = Date.now() + expiresIn * 1000;
142
+ setAuth({
143
+ device_token: token,
144
+ expires_at: expiresAt,
145
+ token_type: "device",
146
+ });
147
+
148
+ // Save user
149
+ setUser(user);
150
+
151
+ // Save organizations
152
+ setOrganizations(organizations);
153
+
154
+ return { user, organizations };
155
+ }