@onesi/genesis-cli 0.0.1-stage → 0.2.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 +64 -0
- package/bin/genesis.js +3 -0
- package/dist/auth.d.ts +30 -0
- package/dist/auth.js +273 -0
- package/dist/beacons.d.ts +3 -0
- package/dist/beacons.js +162 -0
- package/dist/device-login.d.ts +25 -0
- package/dist/device-login.js +173 -0
- package/dist/dials.d.ts +7 -0
- package/dist/dials.js +99 -0
- package/dist/fleets.d.ts +4 -0
- package/dist/fleets.js +102 -0
- package/dist/flocks.d.ts +9 -0
- package/dist/flocks.js +326 -0
- package/dist/function-input.d.ts +3 -0
- package/dist/function-input.js +49 -0
- package/dist/henry.d.ts +7 -0
- package/dist/henry.js +85 -0
- package/dist/hives-coverage.d.ts +12 -0
- package/dist/hives-coverage.js +116 -0
- package/dist/hives-marketplace.d.ts +10 -0
- package/dist/hives-marketplace.js +29 -0
- package/dist/hives-node-transport.d.ts +6 -0
- package/dist/hives-node-transport.js +150 -0
- package/dist/hives-operations.d.ts +10 -0
- package/dist/hives-operations.js +63 -0
- package/dist/hives-platform.d.ts +13 -0
- package/dist/hives-platform.js +133 -0
- package/dist/hives-resources.d.ts +9 -0
- package/dist/hives-resources.js +94 -0
- package/dist/iam-directory.d.ts +15 -0
- package/dist/iam-directory.js +71 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +872 -0
- package/dist/lifeid-native.d.ts +24 -0
- package/dist/lifeid-native.js +113 -0
- package/dist/minds-owner.d.ts +3 -0
- package/dist/minds-owner.js +42 -0
- package/dist/minds.d.ts +3 -0
- package/dist/minds.js +125 -0
- package/dist/onboard/art.d.ts +50 -0
- package/dist/onboard/art.js +139 -0
- package/dist/onboard/brand.d.ts +56 -0
- package/dist/onboard/brand.js +134 -0
- package/dist/onboard/caps.d.ts +90 -0
- package/dist/onboard/caps.js +232 -0
- package/dist/onboard/encode.d.ts +23 -0
- package/dist/onboard/encode.js +235 -0
- package/dist/onboard/eye.d.ts +103 -0
- package/dist/onboard/eye.js +147 -0
- package/dist/onboard/index.d.ts +61 -0
- package/dist/onboard/index.js +562 -0
- package/dist/onboard/mcp.d.ts +35 -0
- package/dist/onboard/mcp.js +118 -0
- package/dist/onboard/raster.d.ts +43 -0
- package/dist/onboard/raster.js +253 -0
- package/dist/onboard/sound.d.ts +2 -0
- package/dist/onboard/sound.js +54 -0
- package/dist/onboard/state.d.ts +24 -0
- package/dist/onboard/state.js +48 -0
- package/dist/onboard/term.d.ts +69 -0
- package/dist/onboard/term.js +292 -0
- package/dist/onboard/ui.d.ts +97 -0
- package/dist/onboard/ui.js +615 -0
- package/dist/orgs.d.ts +16 -0
- package/dist/orgs.js +89 -0
- package/dist/output.d.ts +28 -0
- package/dist/output.js +46 -0
- package/dist/products.d.ts +18 -0
- package/dist/products.js +311 -0
- package/dist/relays-commands/certificates.d.ts +3 -0
- package/dist/relays-commands/certificates.js +20 -0
- package/dist/relays-commands/domains.d.ts +3 -0
- package/dist/relays-commands/domains.js +23 -0
- package/dist/relays-commands/index.d.ts +4 -0
- package/dist/relays-commands/index.js +10 -0
- package/dist/relays-commands/monitoring.d.ts +3 -0
- package/dist/relays-commands/monitoring.js +41 -0
- package/dist/relays-commands/telemetry.d.ts +3 -0
- package/dist/relays-commands/telemetry.js +28 -0
- package/dist/relays-commands/zones.d.ts +5 -0
- package/dist/relays-commands/zones.js +9 -0
- package/dist/relays-enrollment.d.ts +3 -0
- package/dist/relays-enrollment.js +7 -0
- package/dist/runner.d.ts +4 -0
- package/dist/runner.js +57 -0
- package/dist/secret-input.d.ts +14 -0
- package/dist/secret-input.js +85 -0
- package/dist/store.d.ts +39 -0
- package/dist/store.js +33 -0
- package/dist/wasm-input.d.ts +2 -0
- package/dist/wasm-input.js +31 -0
- package/package.json +54 -1
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# @onesi/genesis-cli
|
|
2
|
+
|
|
3
|
+
`genesis`, the OneOS command line. It works against the Genesis API at `https://os.one.ml/api`.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx -y @onesi/genesis-cli@0.2.0 --help
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## First run
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npx -y @onesi/genesis-cli # or: npm i -g @onesi/genesis-cli, then genesis
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
On a terminal, the first `genesis` opens `genesis setup`: six steps, about a minute, each skippable and resumable.
|
|
16
|
+
|
|
17
|
+

|
|
18
|
+
|
|
19
|
+
1. **Sign in with One**, in this browser or with a code on any device.
|
|
20
|
+
2. **Organization, project and environment**, picked from your own (Sandbox).
|
|
21
|
+
3. **An API key** for CI and other machines, only if you want one: read-only, your access, or chosen scopes; shown once or kept in Blinds.
|
|
22
|
+
4. **Your agents**: the Genesis MCP server added to Claude Code, Claude Desktop, Cursor, VS Code, Windsurf or Codex. You see every file or command first; JSON files keep their other servers and a `.genesis-backup` copy.
|
|
23
|
+
5. **A first live call**: every service's state and latency from the gateway.
|
|
24
|
+
6. **What to do next.**
|
|
25
|
+
|
|
26
|
+
Enter takes the default, `s` skips, Ctrl-C pauses. `genesis setup` picks up at the first open step, `genesis setup <step>` redoes one (`signin`, `context`, `key`, `mcp`, `try`, `next`), and `genesis setup --restart` starts over. `genesis setup --json` reports progress for scripts. Progress lives in `~/.config/genesis/setup.json` (no credentials; the login stays in the OS keychain).
|
|
27
|
+
|
|
28
|
+
Setup draws with what your terminal can do, and asks it first: truecolor with edges blended against your real background (or your own ink when the terminal does not say), synchronized output, OSC 8 links, tab progress, and a desktop notification when sign-in finishes while you are in the browser. It is still with `NO_COLOR`, `TERM=dumb`, in CI or a pipe, with `--no-animation` or `GENESIS_REDUCED_MOTION=1`, and when your system asks for reduced motion. `--screen-reader` (or `GENESIS_SCREEN_READER=1`, or VoiceOver running) gives linear output with numbered choices.
|
|
29
|
+
|
|
30
|
+
| Signing in with a code | Still, with `NO_COLOR` and `--no-animation` |
|
|
31
|
+
|---|---|
|
|
32
|
+
|  |  |
|
|
33
|
+
|
|
34
|
+
The recordings were made with `--screencast` against the live services; `docs/media/genesis-setup/*.tape` are the [VHS](https://github.com/charmbracelet/vhs) scripts. `--sound` adds a soft chime. `--screencast` (or `GENESIS_SCREENCAST=1`) masks account names, emails and IDs, for recording demos. Only `genesis setup` and a bare `genesis` load any of this; other commands start exactly as fast as before.
|
|
35
|
+
|
|
36
|
+
## Sign in
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
genesis login # opens your browser to sign in with One
|
|
40
|
+
genesis login --device # on a machine without a browser: approve a code from any signed-in browser
|
|
41
|
+
genesis whoami
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
For scripts and CI, skip login and set an organization API key from the Console (https://os.one.ml → API keys):
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
GENESIS_API_KEY=sk_... genesis hives projects list
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Use
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
genesis status # live state of each service
|
|
54
|
+
genesis hives projects list
|
|
55
|
+
genesis hives projects create my-app --kind worker
|
|
56
|
+
genesis hives projects delete prj_...
|
|
57
|
+
genesis --json silos instances list # machine-readable output; errors include code, required_scope and request_id
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`genesis <service> --help` lists every command for a service. `--base-url` or `GENESIS_BASE_URL` overrides the gateway, and `https://console.life.one/api` is an alias. Requires Node.js 20.19 or later.
|
|
61
|
+
|
|
62
|
+
Use the same version as [`@onesi/genesis`](https://www.npmjs.com/package/@onesi/genesis) and [`@onesi/genesis-mcp`](https://www.npmjs.com/package/@onesi/genesis-mcp).
|
|
63
|
+
|
|
64
|
+
Docs: https://os.one.ml/docs · Error codes: https://os.one.ml/api/v1/errors
|
package/bin/genesis.js
ADDED
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type GenesisContextSelection } from '@onesi/genesis';
|
|
2
|
+
import type { CredentialStore, SavedLogin } from './store.js';
|
|
3
|
+
import { type NativeAttempt, type NativeRegistration } from './lifeid-native.js';
|
|
4
|
+
/** One account's admitted native product bridge, never a direct AuthKit bearer. */
|
|
5
|
+
export interface AuthConfig {
|
|
6
|
+
productId?: string;
|
|
7
|
+
redirectUri?: string;
|
|
8
|
+
audience?: string;
|
|
9
|
+
authApi: string;
|
|
10
|
+
fetch: typeof fetch;
|
|
11
|
+
sleep?: (ms: number) => Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
/** One account's registration for the Genesis CLI (product `genesis-cli`, its one loopback callback, the platform audience). */
|
|
14
|
+
export declare const GENESIS_CLI_REGISTRATION: NativeRegistration;
|
|
15
|
+
/** The registered profile by default; GENESIS_AUTH_* override it (an empty value disables browser login). */
|
|
16
|
+
export declare function authConfig(env?: NodeJS.ProcessEnv, f?: typeof fetch): AuthConfig;
|
|
17
|
+
export declare function startLogin(c: AuthConfig, options?: {
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
organizationHint?: string;
|
|
21
|
+
}): Promise<NativeAttempt>;
|
|
22
|
+
/** Scheduling/consistency only. The One account server and Genesis verifier own authority. */
|
|
23
|
+
export declare function jwtExp(token: string): number | undefined;
|
|
24
|
+
export declare function parseLifeSession(c: AuthConfig, value: unknown, now?: number): SavedLogin;
|
|
25
|
+
export declare function completeLogin(c: AuthConfig, attempt: NativeAttempt): Promise<SavedLogin>;
|
|
26
|
+
export declare function refresh(c: AuthConfig, s: SavedLogin, organizationId?: string): Promise<SavedLogin>;
|
|
27
|
+
/** Commit both replacement credentials through the existing atomic keychain entry. */
|
|
28
|
+
export declare function selectSavedContext(c: AuthConfig, store: CredentialStore, selection: GenesisContextSelection, signal?: AbortSignal): Promise<SavedLogin>;
|
|
29
|
+
/** OS-keychain bridge session; one committed rotation per store, never raw AuthKit. */
|
|
30
|
+
export declare function savedLoginProvider(c: AuthConfig, store: CredentialStore, now?: () => number): () => Promise<string | undefined>;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { selectGenesisContext } from '@onesi/genesis';
|
|
2
|
+
import { refreshDeviceLogin, requireDeviceSaved } from './device-login.js';
|
|
3
|
+
import { LIFEID_ISSUER, startNativeAttempt, validateRegistration } from './lifeid-native.js';
|
|
4
|
+
/** One account's registration for the Genesis CLI (product `genesis-cli`, its one loopback callback, the platform audience). */
|
|
5
|
+
export const GENESIS_CLI_REGISTRATION = {
|
|
6
|
+
productId: 'genesis-cli', redirectUri: 'http://127.0.0.1:53682/lifeid/callback', audience: 'l1fe-platform',
|
|
7
|
+
};
|
|
8
|
+
/** The registered profile by default; GENESIS_AUTH_* override it (an empty value disables browser login). */
|
|
9
|
+
export function authConfig(env = process.env, f = fetch) {
|
|
10
|
+
return { productId: env.GENESIS_AUTH_PRODUCT ?? GENESIS_CLI_REGISTRATION.productId,
|
|
11
|
+
redirectUri: env.GENESIS_AUTH_REDIRECT_URI ?? GENESIS_CLI_REGISTRATION.redirectUri,
|
|
12
|
+
audience: env.GENESIS_AUTH_AUDIENCE ?? GENESIS_CLI_REGISTRATION.audience, authApi: LIFEID_ISSUER, fetch: f };
|
|
13
|
+
}
|
|
14
|
+
function registration(c) {
|
|
15
|
+
if (c.authApi !== LIFEID_ISSUER || !c.productId || !c.redirectUri || !c.audience) {
|
|
16
|
+
throw new Error('One account native login is not admitted here. Configure GENESIS_AUTH_PRODUCT, GENESIS_AUTH_REDIRECT_URI and GENESIS_AUTH_AUDIENCE from the registered One account profile, or use GENESIS_API_KEY.');
|
|
17
|
+
}
|
|
18
|
+
const r = { productId: c.productId, redirectUri: c.redirectUri, audience: c.audience };
|
|
19
|
+
validateRegistration(r);
|
|
20
|
+
return r;
|
|
21
|
+
}
|
|
22
|
+
export async function startLogin(c, options = {}) {
|
|
23
|
+
return startNativeAttempt(registration(c), options);
|
|
24
|
+
}
|
|
25
|
+
function object(v) {
|
|
26
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
|
|
27
|
+
}
|
|
28
|
+
function tokenClaims(token) {
|
|
29
|
+
try {
|
|
30
|
+
const parts = token.split('.');
|
|
31
|
+
if (parts.length !== 3 || !parts.every(p => /^[A-Za-z0-9_-]+$/.test(p)) || token.length > 16_384)
|
|
32
|
+
return undefined;
|
|
33
|
+
return object(JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Scheduling/consistency only. The One account server and Genesis verifier own authority. */
|
|
40
|
+
export function jwtExp(token) {
|
|
41
|
+
const exp = tokenClaims(token)?.exp;
|
|
42
|
+
return typeof exp === 'number' && Number.isSafeInteger(exp) ? exp : undefined;
|
|
43
|
+
}
|
|
44
|
+
function id(v, prefix) {
|
|
45
|
+
return typeof v === 'string' && new RegExp(`^${prefix}_[0-9A-HJKMNP-TV-Z]{26}$`).test(v);
|
|
46
|
+
}
|
|
47
|
+
function secret(v) {
|
|
48
|
+
return typeof v === 'string' && v.length > 0 && v.length <= 16_384 && !/[\s\x00-\x1f\x7f]/.test(v);
|
|
49
|
+
}
|
|
50
|
+
class LifeIdRequestError extends Error {
|
|
51
|
+
}
|
|
52
|
+
function invalidEnvelope() { throw new LifeIdRequestError('One account returned an invalid or mismatched session. Run `genesis login` again.'); }
|
|
53
|
+
export function parseLifeSession(c, value, now = Date.now() / 1000) {
|
|
54
|
+
const r = registration(c);
|
|
55
|
+
const body = object(value), s = object(body?.session), p = object(body?.principal);
|
|
56
|
+
if (!s || !p || p.kind !== 'human' || !id(p.id, 'h') || s.principalKind !== 'human'
|
|
57
|
+
|| !id(s.id, 'sess') || !id(s.organizationId, 'o') || s.issuer !== LIFEID_ISSUER
|
|
58
|
+
|| s.audience !== r.audience || !secret(s.accessToken) || !secret(body?.refresh_token)
|
|
59
|
+
|| typeof s.issuedAt !== 'string' || typeof s.expiresAt !== 'string')
|
|
60
|
+
invalidEnvelope();
|
|
61
|
+
const issued = Date.parse(s.issuedAt) / 1000, expires = Date.parse(s.expiresAt) / 1000;
|
|
62
|
+
const claims = tokenClaims(s.accessToken);
|
|
63
|
+
const exp = jwtExp(s.accessToken);
|
|
64
|
+
const audiences = typeof claims?.aud === 'string' ? [claims.aud] : claims?.aud;
|
|
65
|
+
if (!Number.isFinite(issued) || !Number.isFinite(expires) || issued > now + 60 || expires <= now || expires <= issued
|
|
66
|
+
|| !claims || !exp || exp <= now || claims.iss !== LIFEID_ISSUER || !Array.isArray(audiences)
|
|
67
|
+
|| !audiences.every(a => typeof a === 'string') || !audiences.includes(r.audience)
|
|
68
|
+
|| claims.kind !== 'human' || claims.sid !== s.id || claims.sub !== p.id || claims.tid !== p.id || claims.org !== s.organizationId
|
|
69
|
+
|| (s.tenantId !== undefined && s.tenantId !== null && s.tenantId !== p.id)
|
|
70
|
+
|| claims.cnf !== undefined || claims.cap !== undefined || claims.obo !== undefined)
|
|
71
|
+
invalidEnvelope();
|
|
72
|
+
// The One account scope is the principal; the selected organization stays separate.
|
|
73
|
+
// Optional response projections are not required to retain committed rotation.
|
|
74
|
+
return { access_token: s.accessToken, refresh_token: body.refresh_token,
|
|
75
|
+
user: { id: p.id, ...(typeof p.email === 'string' ? { email: p.email } : {}) },
|
|
76
|
+
organization_id: s.organizationId, expires_at: Math.min(expires, exp), client_id: r.productId,
|
|
77
|
+
auth: { kind: 'lifeid-bridge', issuer: LIFEID_ISSUER, audience: r.audience, product_id: r.productId,
|
|
78
|
+
redirect_uri: r.redirectUri, principal_id: p.id, session_id: s.id } };
|
|
79
|
+
}
|
|
80
|
+
/** Bound headers AND body, forbid redirects, and never echo a provider payload. */
|
|
81
|
+
async function post(c, path, body, signal) {
|
|
82
|
+
registration(c);
|
|
83
|
+
if (signal?.aborted)
|
|
84
|
+
throw new LifeIdRequestError('One account request cancelled. No automatic retry was made.');
|
|
85
|
+
const controller = new AbortController();
|
|
86
|
+
let reader;
|
|
87
|
+
let response;
|
|
88
|
+
let rejectDeadline;
|
|
89
|
+
const deadline = new Promise((_, reject) => { rejectDeadline = reject; });
|
|
90
|
+
const abort = () => { controller.abort(); rejectDeadline(new LifeIdRequestError('One account request cancelled. No automatic retry was made.')); };
|
|
91
|
+
const timer = setTimeout(() => {
|
|
92
|
+
controller.abort();
|
|
93
|
+
rejectDeadline(new LifeIdRequestError('One account request timed out. No automatic retry was made.'));
|
|
94
|
+
}, 15_000);
|
|
95
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
96
|
+
if (signal?.aborted)
|
|
97
|
+
abort();
|
|
98
|
+
try {
|
|
99
|
+
return await Promise.race([deadline, (async () => {
|
|
100
|
+
if (controller.signal.aborted)
|
|
101
|
+
throw new LifeIdRequestError('One account request cancelled.');
|
|
102
|
+
response = await c.fetch(`${LIFEID_ISSUER}${path}`, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
103
|
+
body: JSON.stringify(body), redirect: 'error', cache: 'no-store', signal: controller.signal });
|
|
104
|
+
if (controller.signal.aborted) {
|
|
105
|
+
void response.body?.cancel().catch(() => { });
|
|
106
|
+
throw new LifeIdRequestError('One account request cancelled.');
|
|
107
|
+
}
|
|
108
|
+
if (!response.ok || !/^application\/json(?:;|$)/i.test(response.headers.get('content-type') ?? '')) {
|
|
109
|
+
void response.body?.cancel().catch(() => { });
|
|
110
|
+
throw new LifeIdRequestError(`One account request failed (${response.status}). Run \`genesis login\` again.`);
|
|
111
|
+
}
|
|
112
|
+
reader = response.body?.getReader();
|
|
113
|
+
if (!reader)
|
|
114
|
+
invalidEnvelope();
|
|
115
|
+
const chunks = [];
|
|
116
|
+
let size = 0;
|
|
117
|
+
while (true) {
|
|
118
|
+
const part = await reader.read();
|
|
119
|
+
if (part.done)
|
|
120
|
+
break;
|
|
121
|
+
size += part.value.byteLength;
|
|
122
|
+
if (size > 32_768) {
|
|
123
|
+
void reader.cancel().catch(() => { });
|
|
124
|
+
invalidEnvelope();
|
|
125
|
+
}
|
|
126
|
+
chunks.push(part.value);
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
invalidEnvelope();
|
|
133
|
+
}
|
|
134
|
+
})()]);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (error instanceof LifeIdRequestError)
|
|
138
|
+
throw error;
|
|
139
|
+
throw new Error('One account transport failed. No automatic retry was made.');
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
signal?.removeEventListener('abort', abort);
|
|
144
|
+
if (controller.signal.aborted)
|
|
145
|
+
void reader?.cancel().catch(() => { });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
export async function completeLogin(c, attempt) {
|
|
149
|
+
try {
|
|
150
|
+
const r = registration(c);
|
|
151
|
+
if (r.productId !== attempt.registration.productId || r.redirectUri !== attempt.registration.redirectUri
|
|
152
|
+
|| r.audience !== attempt.registration.audience)
|
|
153
|
+
throw new Error('One account login registration changed.');
|
|
154
|
+
const code = await attempt.code;
|
|
155
|
+
const body = await post(c, '/v4/lifeid/exchange', { life_code: code, product_id: r.productId,
|
|
156
|
+
redirect_uri: r.redirectUri, code_verifier: attempt.codeVerifier }, attempt.signal);
|
|
157
|
+
return parseLifeSession(c, body);
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
attempt.close();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function requireSaved(c, s) {
|
|
164
|
+
if (s.auth?.kind === 'lifeid-device') {
|
|
165
|
+
requireDeviceSaved(s);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const r = registration(c), a = s.auth;
|
|
169
|
+
if (!a || a.kind !== 'lifeid-bridge' || a.issuer !== LIFEID_ISSUER || a.audience !== r.audience
|
|
170
|
+
|| a.product_id !== r.productId || a.redirect_uri !== r.redirectUri || s.client_id !== r.productId
|
|
171
|
+
|| !id(s.organization_id, 'o') || !id(a.principal_id, 'h') || !id(a.session_id, 'sess')) {
|
|
172
|
+
throw new Error('Saved login is not an admitted One account bridge session. Run `genesis login` again.');
|
|
173
|
+
}
|
|
174
|
+
const claims = tokenClaims(s.access_token), aud = claims?.aud;
|
|
175
|
+
if (!claims || claims.iss !== a.issuer || !(aud === a.audience || (Array.isArray(aud) && aud.includes(a.audience)))
|
|
176
|
+
|| claims.kind !== 'human' || claims.sid !== a.session_id || claims.sub !== a.principal_id || claims.tid !== a.principal_id
|
|
177
|
+
|| claims.org !== s.organization_id || claims.cnf !== undefined || claims.cap !== undefined || claims.obo !== undefined) {
|
|
178
|
+
throw new Error('Saved One account session is inconsistent. Run `genesis login` again.');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
export async function refresh(c, s, organizationId) {
|
|
182
|
+
requireSaved(c, s);
|
|
183
|
+
if (s.auth?.kind === 'lifeid-device') {
|
|
184
|
+
if (organizationId && organizationId !== s.organization_id) {
|
|
185
|
+
throw new Error('Changing organization requires a new verified One account login; refresh cannot select an organization.');
|
|
186
|
+
}
|
|
187
|
+
return refreshDeviceLogin(c, s);
|
|
188
|
+
}
|
|
189
|
+
if (organizationId && organizationId !== s.organization_id) {
|
|
190
|
+
throw new Error('Changing organization requires a new verified One account login; refresh cannot select an organization.');
|
|
191
|
+
}
|
|
192
|
+
if (!secret(s.refresh_token))
|
|
193
|
+
throw new Error('Session expired. Run `genesis login`.');
|
|
194
|
+
const n = parseLifeSession(c, await post(c, '/v4/auth/refresh', { refresh_token: s.refresh_token }));
|
|
195
|
+
if (n.organization_id !== s.organization_id || n.auth?.principal_id !== s.auth?.principal_id)
|
|
196
|
+
invalidEnvelope();
|
|
197
|
+
return n;
|
|
198
|
+
}
|
|
199
|
+
const rotations = new WeakMap();
|
|
200
|
+
/** Queue before the first await, so parallel selection and refresh cannot use
|
|
201
|
+
* the same spent credential. A failed predecessor does not trigger a retry. */
|
|
202
|
+
function serialize(store, run) {
|
|
203
|
+
const previous = rotations.get(store);
|
|
204
|
+
const pending = previous ? previous.then(run) : Promise.resolve().then(run);
|
|
205
|
+
rotations.set(store, pending);
|
|
206
|
+
return pending.finally(() => { if (rotations.get(store) === pending)
|
|
207
|
+
rotations.delete(store); });
|
|
208
|
+
}
|
|
209
|
+
async function commit(store, value) {
|
|
210
|
+
try {
|
|
211
|
+
await store.set(value);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
throw new Error('The returned One account session could not be saved. No automatic retry was made.');
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
async function currentLogin(c, store) {
|
|
218
|
+
const saved = await store.get();
|
|
219
|
+
if (!saved)
|
|
220
|
+
throw new Error('Run `genesis login` before selecting a context.');
|
|
221
|
+
requireSaved(c, saved);
|
|
222
|
+
return saved;
|
|
223
|
+
}
|
|
224
|
+
function needsRefresh(saved, now) {
|
|
225
|
+
return Math.min(saved.expires_at ?? 0, jwtExp(saved.access_token) ?? 0) - 60 <= now;
|
|
226
|
+
}
|
|
227
|
+
/** Commit both replacement credentials through the existing atomic keychain entry. */
|
|
228
|
+
export async function selectSavedContext(c, store, selection, signal) {
|
|
229
|
+
const requested = { ...selection };
|
|
230
|
+
return serialize(store, async () => {
|
|
231
|
+
let saved;
|
|
232
|
+
const response = await selectGenesisContext(requested, { signal, fetch: c.fetch, credential: async () => {
|
|
233
|
+
signal?.throwIfAborted();
|
|
234
|
+
saved = await currentLogin(c, store);
|
|
235
|
+
if (needsRefresh(saved, Date.now() / 1000)) {
|
|
236
|
+
saved = await refresh(c, saved);
|
|
237
|
+
await commit(store, saved);
|
|
238
|
+
}
|
|
239
|
+
signal?.throwIfAborted();
|
|
240
|
+
return saved.access_token;
|
|
241
|
+
} });
|
|
242
|
+
const next = parseLifeSession(c, response);
|
|
243
|
+
if (next.auth?.principal_id !== saved?.auth?.principal_id || next.organization_id !== requested.organizationId)
|
|
244
|
+
invalidEnvelope();
|
|
245
|
+
await commit(store, next);
|
|
246
|
+
return next;
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
/** OS-keychain bridge session; one committed rotation per store, never raw AuthKit. */
|
|
250
|
+
export function savedLoginProvider(c, store, now = () => Date.now() / 1000) {
|
|
251
|
+
return async () => {
|
|
252
|
+
const mutation = rotations.get(store);
|
|
253
|
+
if (mutation)
|
|
254
|
+
return (await mutation).access_token;
|
|
255
|
+
const saved = await store.get();
|
|
256
|
+
if (!saved)
|
|
257
|
+
return undefined;
|
|
258
|
+
requireSaved(c, saved);
|
|
259
|
+
const newer = rotations.get(store);
|
|
260
|
+
if (newer)
|
|
261
|
+
return (await newer).access_token;
|
|
262
|
+
if (!needsRefresh(saved, now()))
|
|
263
|
+
return saved.access_token;
|
|
264
|
+
return (await serialize(store, async () => {
|
|
265
|
+
const current = await currentLogin(c, store);
|
|
266
|
+
if (!needsRefresh(current, now()))
|
|
267
|
+
return current;
|
|
268
|
+
const next = await refresh(c, current);
|
|
269
|
+
await commit(store, next);
|
|
270
|
+
return next;
|
|
271
|
+
})).access_token;
|
|
272
|
+
};
|
|
273
|
+
}
|
package/dist/beacons.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { createPrivateKey, sign } from 'node:crypto';
|
|
3
|
+
export function registerBeacons(program, client, emit) {
|
|
4
|
+
const beacons = program.command('beacons').description('Beacons: Agent-native mesh');
|
|
5
|
+
const fleets = beacons.command('fleets').description('Fleet metadata and policy in your verified selection');
|
|
6
|
+
fleets.command('list').description('List actual fleets in the current authorized Sandbox scope').action(async () => {
|
|
7
|
+
const result = await (await client()).beacons.fleets.list();
|
|
8
|
+
emit(result, () => JSON.stringify(result, null, 2));
|
|
9
|
+
});
|
|
10
|
+
fleets.command('rename <fleet-id>').requiredOption('--name <name>', 'Fleet name, at most 128 UTF-8 bytes').action(async (id, opts) => {
|
|
11
|
+
const result = await (await client()).beacons.fleets.update(id, { name: opts.name });
|
|
12
|
+
emit(result, () => JSON.stringify(result, null, 2));
|
|
13
|
+
});
|
|
14
|
+
fleets.command('peers <fleet-id>').description('List actual peers, including durable revocation state').action(async (id) => {
|
|
15
|
+
const result = await (await client()).beacons.fleets.peers(id);
|
|
16
|
+
emit(result, () => JSON.stringify(result, null, 2));
|
|
17
|
+
});
|
|
18
|
+
fleets.command('get <fleet-id>').description('Read one fleet in the current authorized scope').action(async (id) => {
|
|
19
|
+
const result = await (await client()).beacons.fleets.get(id);
|
|
20
|
+
emit(result, () => JSON.stringify(result, null, 2));
|
|
21
|
+
});
|
|
22
|
+
fleets.command('dns <fleet-id>').description('Mesh DNS map: one A record per live peer').action(async (id) => {
|
|
23
|
+
const result = await (await client()).beacons.fleets.dns(id);
|
|
24
|
+
emit(result, () => JSON.stringify(result, null, 2));
|
|
25
|
+
});
|
|
26
|
+
fleets.command('discovery <fleet-id>').description("Mesh discovery map: live peers' public keys, DNS and relay map").action(async (id) => {
|
|
27
|
+
const result = await (await client()).beacons.fleets.discovery(id);
|
|
28
|
+
emit(result, () => JSON.stringify(result, null, 2));
|
|
29
|
+
});
|
|
30
|
+
fleets.command('client-profile <fleet-id> <platform>').description('beacons-agent profile: linux, macos, windows, ios or android').action(async (id, platform) => {
|
|
31
|
+
const result = await (await client()).beacons.fleets.clientProfile(id, platform);
|
|
32
|
+
emit(result, () => JSON.stringify(result, null, 2));
|
|
33
|
+
});
|
|
34
|
+
fleets.command('wireguard-config <fleet-id> <peer-id>').description("One peer's WireGuard config: other peers' public keys, never a private key").action(async (id, peer) => {
|
|
35
|
+
const result = await (await client()).beacons.fleets.wireguardConfig(id, peer);
|
|
36
|
+
emit(result, () => JSON.stringify(result, null, 2));
|
|
37
|
+
});
|
|
38
|
+
const policy = fleets.command('policy').description('Future enrollment admission; does not grant permissions or revoke devices');
|
|
39
|
+
policy.command('get <fleet-id>').action(async (id) => {
|
|
40
|
+
const result = await (await client()).beacons.fleets.policy.get(id);
|
|
41
|
+
emit(result, () => JSON.stringify(result, null, 2));
|
|
42
|
+
});
|
|
43
|
+
policy.command('set <fleet-id>').requiredOption('--device-enrollment <mode>', 'allowed_with_permission or paused')
|
|
44
|
+
.option('--max-devices <count>', 'Integer 1–10000; omit to save the policy without a device cap')
|
|
45
|
+
.action(async (id, opts) => {
|
|
46
|
+
if (opts.deviceEnrollment !== 'allowed_with_permission' && opts.deviceEnrollment !== 'paused')
|
|
47
|
+
throw new Error('Invalid device enrollment mode.');
|
|
48
|
+
if (opts.maxDevices !== undefined && !/^[1-9][0-9]{0,4}$/.test(opts.maxDevices))
|
|
49
|
+
throw new Error('Invalid maximum device count.');
|
|
50
|
+
const result = await (await client()).beacons.fleets.policy.update(id, {
|
|
51
|
+
device_enrollment: opts.deviceEnrollment,
|
|
52
|
+
...(opts.maxDevices === undefined ? {} : { max_devices: Number(opts.maxDevices) }),
|
|
53
|
+
});
|
|
54
|
+
emit(result, () => JSON.stringify(result, null, 2));
|
|
55
|
+
});
|
|
56
|
+
const resources = fleets.command('resources').description('Fleet configuration records: gateways, private-gateways, sims (configuration only)');
|
|
57
|
+
const family = (value) => {
|
|
58
|
+
if (!['gateways', 'private-gateways', 'sims'].includes(value))
|
|
59
|
+
throw new Error('Family must be gateways, private-gateways or sims.');
|
|
60
|
+
return value;
|
|
61
|
+
};
|
|
62
|
+
const config = (value) => {
|
|
63
|
+
let parsed;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(value);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
throw new Error('--config must be a JSON object.');
|
|
69
|
+
}
|
|
70
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
71
|
+
throw new Error('--config must be a JSON object.');
|
|
72
|
+
return parsed;
|
|
73
|
+
};
|
|
74
|
+
resources.command('list <fleet-id> <family>').description('List the fleet\'s records of one family').action(async (id, kind) => {
|
|
75
|
+
const result = await (await client()).beacons.fleets.resources.list(id, family(kind));
|
|
76
|
+
emit(result, () => JSON.stringify(result, null, 2));
|
|
77
|
+
});
|
|
78
|
+
resources.command('create <fleet-id> <family>').description('Create one record; not idempotent')
|
|
79
|
+
.requiredOption('--name <name>', 'Record name, at most 128 UTF-8 bytes')
|
|
80
|
+
.requiredOption('--status <status>', 'active, inactive, disabled or pending')
|
|
81
|
+
.option('--config <json>', 'Family config object, e.g. \'{"type":"relay","routes":["10.0.0.0/24"]}\'', '{}')
|
|
82
|
+
.action(async (id, kind, opts) => {
|
|
83
|
+
const result = await (await client()).beacons.fleets.resources.create(id, family(kind), { name: opts.name, status: opts.status, config: config(opts.config) });
|
|
84
|
+
emit(result, () => JSON.stringify(result, null, 2));
|
|
85
|
+
});
|
|
86
|
+
resources.command('update <fleet-id> <family> <resource-id>').description('Replace the given fields of one record')
|
|
87
|
+
.option('--name <name>', 'New name').option('--status <status>', 'active, inactive, disabled or pending')
|
|
88
|
+
.option('--config <json>', 'Replacement family config object')
|
|
89
|
+
.action(async (id, kind, resourceId, opts) => {
|
|
90
|
+
if (opts.name === undefined && opts.status === undefined && opts.config === undefined)
|
|
91
|
+
throw new Error('Supply --name, --status or --config.');
|
|
92
|
+
const result = await (await client()).beacons.fleets.resources.update(id, family(kind), resourceId, {
|
|
93
|
+
...(opts.name === undefined ? {} : { name: opts.name }), ...(opts.status === undefined ? {} : { status: opts.status }),
|
|
94
|
+
...(opts.config === undefined ? {} : { config: config(opts.config) })
|
|
95
|
+
});
|
|
96
|
+
emit(result, () => JSON.stringify(result, null, 2));
|
|
97
|
+
});
|
|
98
|
+
resources.command('delete <fleet-id> <family> <resource-id>').description('Delete one configuration record')
|
|
99
|
+
.action(async (id, kind, resourceId) => {
|
|
100
|
+
await (await client()).beacons.fleets.resources.delete(id, family(kind), resourceId);
|
|
101
|
+
const result = { deleted: resourceId };
|
|
102
|
+
emit(result, () => `Deleted ${resourceId}`);
|
|
103
|
+
});
|
|
104
|
+
fleets.command('provision').requiredOption('--request-id <uuid>', 'Stable UUID; reuse with the same name on retry')
|
|
105
|
+
.option('--name <name>', 'Optional fleet name')
|
|
106
|
+
.action(async (opts) => {
|
|
107
|
+
const result = await (await client()).beacons.fleets.provision(opts.requestId, opts.name === undefined ? {} : { name: opts.name });
|
|
108
|
+
emit(result, () => JSON.stringify(result, null, 2));
|
|
109
|
+
});
|
|
110
|
+
const devices = beacons.command('devices').description('Manage Bridge devices in your signed-in organization');
|
|
111
|
+
devices.command('challenge <fleet-id>').requiredOption('--proof-key <key>', 'Base64url Ed25519 public key').requiredOption('--tunnel-key <key>', 'Base64url WireGuard public key').option('--transport <transport>', 't1_full or t2_sandboxed', 't1_full')
|
|
112
|
+
.action(async (fleetId, opts) => {
|
|
113
|
+
if (opts.transport !== 't1_full' && opts.transport !== 't2_sandboxed')
|
|
114
|
+
throw new Error('Unsupported device transport.');
|
|
115
|
+
const result = await (await client()).beacons.devices.challenge({ fleet_id: fleetId, proof_key: opts.proofKey, tunnel_key: opts.tunnelKey, transport: opts.transport });
|
|
116
|
+
emit(result, () => JSON.stringify(result, null, 2));
|
|
117
|
+
});
|
|
118
|
+
devices.command('enroll <challenge-file>').requiredOption('--key-file <path>', 'Local Ed25519 PEM private key; never uploaded')
|
|
119
|
+
.action(async (challengeFile, opts) => {
|
|
120
|
+
const challenge = JSON.parse(await readFile(challengeFile, 'utf8'));
|
|
121
|
+
const fields = ['version', 'id', 'nonce', 'issuer', 'organization', 'principal', 'workspace', 'project', 'environment', 'binding_revision', 'transport', 'fleet_id', 'proof_key', 'tunnel_key', 'issued_at', 'expires_at'];
|
|
122
|
+
if (Object.keys(challenge).length !== fields.length || fields.some(field => challenge[field] === undefined))
|
|
123
|
+
throw new Error('Invalid challenge file.');
|
|
124
|
+
const canonical = Object.fromEntries(fields.map(field => [field, challenge[field]]));
|
|
125
|
+
const key = createPrivateKey(await readFile(opts.keyFile));
|
|
126
|
+
if (key.asymmetricKeyType !== 'ed25519')
|
|
127
|
+
throw new Error('An Ed25519 device key is required.');
|
|
128
|
+
const signature = sign(null, Buffer.from('Genesis Beacons device enrollment v1\n' + JSON.stringify(canonical)), key).toString('base64url');
|
|
129
|
+
const result = await (await client()).beacons.devices.enroll({ challenge, signature });
|
|
130
|
+
emit(result, () => JSON.stringify(result, null, 2));
|
|
131
|
+
});
|
|
132
|
+
devices.command('revocations <id>').description('Poll durable transport teardown commands').action(async (id) => {
|
|
133
|
+
const result = await (await client()).beacons.devices.revocations(id);
|
|
134
|
+
emit(result, () => JSON.stringify(result, null, 2));
|
|
135
|
+
});
|
|
136
|
+
devices.command('ack <id> <version>').requiredOption('--action <action>', 'Applied interface_torn_down or peer_key_removed; apply transport action first').action(async (id, version, opts) => {
|
|
137
|
+
const numeric = Number(version);
|
|
138
|
+
if (!Number.isSafeInteger(numeric) || numeric < 1)
|
|
139
|
+
throw new Error('Invalid revocation version.');
|
|
140
|
+
if (opts.action !== 'interface_torn_down' && opts.action !== 'peer_key_removed')
|
|
141
|
+
throw new Error('Invalid transport action.');
|
|
142
|
+
const result = await (await client()).beacons.devices.acknowledge(id, numeric, { action: opts.action });
|
|
143
|
+
emit(result, () => JSON.stringify(result, null, 2));
|
|
144
|
+
});
|
|
145
|
+
devices.command('list', { isDefault: true }).action(async () => {
|
|
146
|
+
const result = await (await client()).beacons.devices.list();
|
|
147
|
+
emit(result, () => JSON.stringify(result, null, 2));
|
|
148
|
+
});
|
|
149
|
+
devices.command('update <id>').option('--name <name>', 'Device name').option('--nickname <nickname...>', 'Replace nicknames').option('--clear-nicknames', 'Remove all nicknames')
|
|
150
|
+
.action(async (id, opts) => {
|
|
151
|
+
if (opts.clearNicknames && opts.nickname)
|
|
152
|
+
throw new Error('Choose --nickname or --clear-nicknames.');
|
|
153
|
+
if (opts.name === undefined && opts.nickname === undefined && !opts.clearNicknames)
|
|
154
|
+
throw new Error('Supply --name, --nickname or --clear-nicknames.');
|
|
155
|
+
const result = await (await client()).beacons.devices.update(id, { ...(opts.name === undefined ? {} : { name: opts.name }), ...(opts.clearNicknames ? { nicknames: [] } : opts.nickname ? { nicknames: opts.nickname } : {}) });
|
|
156
|
+
emit(result, () => JSON.stringify(result, null, 2));
|
|
157
|
+
});
|
|
158
|
+
devices.command('revoke <id>').description('Request durable mesh teardown').action(async (id) => {
|
|
159
|
+
const result = await (await client()).beacons.devices.revoke(id);
|
|
160
|
+
emit(result ?? { status: 'revoked' }, () => JSON.stringify(result ?? { status: 'revoked' }, null, 2));
|
|
161
|
+
});
|
|
162
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { SavedLogin } from './store.js';
|
|
2
|
+
/** RFC 8628 device grant against One account, for agents and machines without a local browser. */
|
|
3
|
+
export declare const DEVICE_CLIENT_ID = "genesis-cli-device";
|
|
4
|
+
export declare const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
5
|
+
export interface DeviceConfig {
|
|
6
|
+
fetch: typeof fetch;
|
|
7
|
+
sleep?: (ms: number) => Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
export interface DeviceStart {
|
|
10
|
+
userCode: string;
|
|
11
|
+
verificationUri: string;
|
|
12
|
+
verificationUriComplete: string;
|
|
13
|
+
expiresIn: number;
|
|
14
|
+
interval: number;
|
|
15
|
+
/** Secret polled by this process; never printed. */
|
|
16
|
+
readonly deviceCode: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function startDeviceLogin(c: DeviceConfig, signal?: AbortSignal): Promise<DeviceStart>;
|
|
19
|
+
/** Saved session from an OAuth token response. Authority stays with the One account verifier. */
|
|
20
|
+
export declare function parseDeviceTokens(body: Record<string, unknown>, now?: number): SavedLogin;
|
|
21
|
+
/** Poll until approved, denied or expired, honouring `interval` and `slow_down`. */
|
|
22
|
+
export declare function completeDeviceLogin(c: DeviceConfig, start: DeviceStart, signal?: AbortSignal): Promise<SavedLogin>;
|
|
23
|
+
/** Rotate a device session through the OAuth refresh grant of the same public client. */
|
|
24
|
+
export declare function refreshDeviceLogin(c: DeviceConfig, saved: SavedLogin): Promise<SavedLogin>;
|
|
25
|
+
export declare function requireDeviceSaved(s: SavedLogin): void;
|