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 +110 -0
- package/README.npm.md +137 -0
- package/bin/kern0 +23 -0
- package/bin/kern0-mcp +2 -0
- package/lib/cli/api.ts +208 -0
- package/lib/cli/auth-flow.ts +155 -0
- package/lib/cli/commands/auth.ts +126 -0
- package/lib/cli/commands/customers.ts +68 -0
- package/lib/cli/commands/environments.ts +74 -0
- package/lib/cli/commands/org.ts +130 -0
- package/lib/cli/commands/tokens.ts +124 -0
- package/lib/cli/config.ts +257 -0
- package/lib/cli/index.ts +222 -0
- package/lib/mcp/api-client.ts +76 -0
- package/lib/mcp/resources.ts +55 -0
- package/lib/mcp/server.ts +216 -0
- package/lib/mcp/tools.ts +288 -0
- package/package.json +76 -0
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
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
|
+
}
|