@stigmer/cli 3.13.0 → 3.14.0-dev.20260910084630
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/auth/login.d.ts.map +1 -1
- package/auth/login.js +30 -5
- package/auth/login.js.map +1 -1
- package/auth/token.d.ts +10 -5
- package/auth/token.d.ts.map +1 -1
- package/auth/token.js +30 -21
- package/auth/token.js.map +1 -1
- package/commands/apikey/index.d.ts.map +1 -1
- package/commands/apikey/index.js +11 -5
- package/commands/apikey/index.js.map +1 -1
- package/commands/auth/index.d.ts.map +1 -1
- package/commands/auth/index.js +19 -8
- package/commands/auth/index.js.map +1 -1
- package/commands/config/backend.d.ts.map +1 -1
- package/commands/config/backend.js +134 -12
- package/commands/config/backend.js.map +1 -1
- package/commands/connect.d.ts.map +1 -1
- package/commands/connect.js +6 -3
- package/commands/connect.js.map +1 -1
- package/commands/share.d.ts.map +1 -1
- package/commands/share.js +8 -6
- package/commands/share.js.map +1 -1
- package/config/config.d.ts +56 -7
- package/config/config.d.ts.map +1 -1
- package/config/config.js +115 -16
- package/config/config.js.map +1 -1
- package/config/index.d.ts +1 -1
- package/config/index.d.ts.map +1 -1
- package/config/index.js +1 -1
- package/config/index.js.map +1 -1
- package/config/keys.d.ts +1 -1
- package/config/keys.d.ts.map +1 -1
- package/config/keys.js +29 -6
- package/config/keys.js.map +1 -1
- package/config/resolve.d.ts +28 -20
- package/config/resolve.d.ts.map +1 -1
- package/config/resolve.js +74 -36
- package/config/resolve.js.map +1 -1
- package/local/seedpack/content.d.ts +3 -3
- package/local/seedpack/content.d.ts.map +1 -1
- package/local/seedpack/content.js +19 -13
- package/local/seedpack/content.js.map +1 -1
- package/package.json +5 -5
- package/resources/connect/connect.d.ts +4 -3
- package/resources/connect/connect.d.ts.map +1 -1
- package/resources/connect/connect.js +17 -5
- package/resources/connect/connect.js.map +1 -1
- package/resources/connect/oauth.d.ts +5 -2
- package/resources/connect/oauth.d.ts.map +1 -1
- package/resources/connect/oauth.js +5 -4
- package/resources/connect/oauth.js.map +1 -1
- package/src/auth/login.ts +54 -9
- package/src/auth/token.test.ts +69 -13
- package/src/auth/token.ts +46 -22
- package/src/client/client.test.ts +10 -2
- package/src/commands/apikey/index.ts +42 -11
- package/src/commands/auth/index.ts +52 -14
- package/src/commands/config/backend.test.ts +122 -0
- package/src/commands/config/backend.ts +195 -14
- package/src/commands/connect.test.ts +22 -4
- package/src/commands/connect.ts +37 -13
- package/src/commands/share.ts +36 -16
- package/src/config/config.test.ts +62 -6
- package/src/config/config.ts +165 -24
- package/src/config/index.ts +6 -0
- package/src/config/keys.test.ts +27 -4
- package/src/config/keys.ts +46 -9
- package/src/config/resolve.test.ts +135 -34
- package/src/config/resolve.ts +83 -36
- package/src/local/seedpack/content.ts +52 -21
- package/src/resources/connect/connect.integration.test.ts +79 -26
- package/src/resources/connect/connect.ts +72 -19
- package/src/resources/connect/oauth.test.ts +38 -12
- package/src/resources/connect/oauth.ts +27 -11
package/src/auth/login.ts
CHANGED
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
// Progress goes to stderr; the command layer renders the final result.
|
|
11
11
|
|
|
12
12
|
import open from "open";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
CLOUD_BACKEND_NAME,
|
|
15
|
+
activeBackend,
|
|
16
|
+
load,
|
|
17
|
+
save,
|
|
18
|
+
} from "../config/index.js";
|
|
14
19
|
import { CliExitError, ExitCode } from "../errors/index.js";
|
|
15
20
|
import { log } from "../logger.js";
|
|
16
21
|
import { buildAuthorizeUrl, CALLBACK_PATH, CALLBACK_PORT } from "./auth0.js";
|
|
@@ -21,6 +26,27 @@ import { exchangeCode } from "./token.js";
|
|
|
21
26
|
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
22
27
|
|
|
23
28
|
export async function login(): Promise<void> {
|
|
29
|
+
// Browser PKCE targets Stigmer Cloud only: the Auth0 coordinates are
|
|
30
|
+
// compiled in. From the local backend, login lands on (creating if
|
|
31
|
+
// needed) the reserved "cloud" entry — today's behavior in the named
|
|
32
|
+
// model. On a selfhost backend the flow refuses with the self-host
|
|
33
|
+
// credential story (API tokens) instead of silently writing cloud
|
|
34
|
+
// credentials the active backend would never send.
|
|
35
|
+
const preflight = load();
|
|
36
|
+
const { name, entry } = activeBackend(preflight);
|
|
37
|
+
if (entry?.type === "selfhost") {
|
|
38
|
+
throw new CliExitError(
|
|
39
|
+
`backend "${name}" is self-hosted — browser login targets Stigmer Cloud only`,
|
|
40
|
+
ExitCode.Usage,
|
|
41
|
+
[
|
|
42
|
+
"Authenticate a self-hosted backend with an API token:",
|
|
43
|
+
" stigmer apikey create --name cli",
|
|
44
|
+
` stigmer config backend add ${name} --endpoint <host:port> --api-key <key>`,
|
|
45
|
+
"or switch first: stigmer config backend use cloud",
|
|
46
|
+
],
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
24
50
|
const verifier = generateVerifier();
|
|
25
51
|
const codeChallenge = challengeS256(verifier);
|
|
26
52
|
const state = generateState();
|
|
@@ -30,30 +56,49 @@ export async function login(): Promise<void> {
|
|
|
30
56
|
try {
|
|
31
57
|
const authUrl = buildAuthorizeUrl({ state, codeChallenge });
|
|
32
58
|
process.stderr.write("Opening browser for authentication...\n");
|
|
33
|
-
process.stderr.write(
|
|
59
|
+
process.stderr.write(
|
|
60
|
+
`If the browser doesn't open automatically, visit:\n${authUrl}\n\n`,
|
|
61
|
+
);
|
|
34
62
|
|
|
35
63
|
try {
|
|
36
64
|
await open(authUrl);
|
|
37
65
|
} catch (err) {
|
|
38
|
-
log.debug(
|
|
39
|
-
|
|
66
|
+
log.debug(
|
|
67
|
+
`failed to open browser automatically: ${(err as Error).message}`,
|
|
68
|
+
);
|
|
69
|
+
process.stderr.write(
|
|
70
|
+
"Could not open the browser automatically. Please open the URL above.\n",
|
|
71
|
+
);
|
|
40
72
|
}
|
|
41
73
|
|
|
42
74
|
const callback = await server.waitForCallback(LOGIN_TIMEOUT_MS);
|
|
43
75
|
if (callback.state !== state) {
|
|
44
|
-
throw new CliExitError(
|
|
76
|
+
throw new CliExitError(
|
|
77
|
+
"OAuth state mismatch — possible CSRF attack, aborting login",
|
|
78
|
+
ExitCode.Auth,
|
|
79
|
+
);
|
|
45
80
|
}
|
|
46
81
|
|
|
47
|
-
const tokens = await exchangeCode({
|
|
82
|
+
const tokens = await exchangeCode({
|
|
83
|
+
code: callback.code,
|
|
84
|
+
codeVerifier: verifier,
|
|
85
|
+
});
|
|
48
86
|
|
|
49
87
|
const config = load();
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
88
|
+
const active = activeBackend(config);
|
|
89
|
+
// A cloud-type backend keeps its own login; anything else (the local
|
|
90
|
+
// daemon) lands on the reserved "cloud" entry and switches to it.
|
|
91
|
+
const targetName =
|
|
92
|
+
active.entry?.type === "cloud" ? active.name : CLOUD_BACKEND_NAME;
|
|
93
|
+
const backends = (config.backends ??= {});
|
|
94
|
+
backends[targetName] = {
|
|
95
|
+
...(backends[targetName] ?? { type: "cloud" }),
|
|
96
|
+
type: "cloud",
|
|
53
97
|
token: tokens.accessToken,
|
|
54
98
|
refresh_token: tokens.refreshToken,
|
|
55
99
|
token_expiry: tokens.expiresAt,
|
|
56
100
|
};
|
|
101
|
+
config.current_backend = targetName;
|
|
57
102
|
save(config);
|
|
58
103
|
} finally {
|
|
59
104
|
await server.shutdown();
|
package/src/auth/token.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs";
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
-
import type { Config } from "../config/index.js";
|
|
5
|
+
import type { Config, NamedBackendConfig } from "../config/index.js";
|
|
6
6
|
import { createRefreshingTokenProvider } from "./token.js";
|
|
7
7
|
|
|
8
8
|
// The provider persists refreshed tokens via config.save(), which writes to
|
|
@@ -10,11 +10,35 @@ import { createRefreshingTokenProvider } from "./token.js";
|
|
|
10
10
|
// the developer's real config.
|
|
11
11
|
let originalHome: string | undefined;
|
|
12
12
|
|
|
13
|
-
function cloudConfig(overrides: Partial<
|
|
14
|
-
return {
|
|
13
|
+
function cloudConfig(overrides: Partial<NamedBackendConfig>): Config {
|
|
14
|
+
return {
|
|
15
|
+
backend: { type: "cloud" },
|
|
16
|
+
backends: {
|
|
17
|
+
cloud: { type: "cloud", endpoint: "api.stigmer.ai:443", ...overrides },
|
|
18
|
+
},
|
|
19
|
+
current_backend: "cloud",
|
|
20
|
+
};
|
|
15
21
|
}
|
|
16
22
|
|
|
17
|
-
function
|
|
23
|
+
function selfhostConfig(overrides: Partial<NamedBackendConfig> = {}): Config {
|
|
24
|
+
return {
|
|
25
|
+
backend: { type: "cloud" },
|
|
26
|
+
backends: {
|
|
27
|
+
staging: {
|
|
28
|
+
type: "selfhost",
|
|
29
|
+
endpoint: "stigmer.example.com:7234",
|
|
30
|
+
...overrides,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
current_backend: "staging",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mockFetch(response: {
|
|
38
|
+
ok: boolean;
|
|
39
|
+
status?: number;
|
|
40
|
+
body: unknown;
|
|
41
|
+
}): void {
|
|
18
42
|
vi.stubGlobal(
|
|
19
43
|
"fetch",
|
|
20
44
|
vi.fn(async () => ({
|
|
@@ -42,7 +66,9 @@ describe("createRefreshingTokenProvider", () => {
|
|
|
42
66
|
process.env.STIGMER_API_KEY = "env-key";
|
|
43
67
|
const fetchSpy = vi.fn();
|
|
44
68
|
vi.stubGlobal("fetch", fetchSpy);
|
|
45
|
-
const provider = createRefreshingTokenProvider(
|
|
69
|
+
const provider = createRefreshingTokenProvider(
|
|
70
|
+
cloudConfig({ token: "stale", token_expiry: past() }),
|
|
71
|
+
);
|
|
46
72
|
expect(await provider()).toBe("env-key");
|
|
47
73
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
48
74
|
});
|
|
@@ -50,24 +76,52 @@ describe("createRefreshingTokenProvider", () => {
|
|
|
50
76
|
it("returns a non-expired token without refreshing", async () => {
|
|
51
77
|
const fetchSpy = vi.fn();
|
|
52
78
|
vi.stubGlobal("fetch", fetchSpy);
|
|
53
|
-
const provider = createRefreshingTokenProvider(
|
|
79
|
+
const provider = createRefreshingTokenProvider(
|
|
80
|
+
cloudConfig({ token: "good", token_expiry: future() }),
|
|
81
|
+
);
|
|
54
82
|
expect(await provider()).toBe("good");
|
|
55
83
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
56
84
|
});
|
|
57
85
|
|
|
58
|
-
it("refreshes an expired token and persists the new one", async () => {
|
|
59
|
-
mockFetch({
|
|
60
|
-
|
|
86
|
+
it("refreshes an expired token and persists the new one into the entry", async () => {
|
|
87
|
+
mockFetch({
|
|
88
|
+
ok: true,
|
|
89
|
+
body: { access_token: "fresh", refresh_token: "r2", expires_in: 3600 },
|
|
90
|
+
});
|
|
91
|
+
const config = cloudConfig({
|
|
92
|
+
token: "expired",
|
|
93
|
+
token_expiry: past(),
|
|
94
|
+
refresh_token: "r1",
|
|
95
|
+
});
|
|
61
96
|
const provider = createRefreshingTokenProvider(config);
|
|
62
97
|
expect(await provider()).toBe("fresh");
|
|
63
|
-
expect(config.
|
|
64
|
-
expect(config.
|
|
98
|
+
expect(config.backends?.["cloud"]?.token).toBe("fresh");
|
|
99
|
+
expect(config.backends?.["cloud"]?.refresh_token).toBe("r2");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("returns the selfhost entry's api_key without any refresh machinery", async () => {
|
|
103
|
+
const fetchSpy = vi.fn();
|
|
104
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
105
|
+
const provider = createRefreshingTokenProvider(
|
|
106
|
+
selfhostConfig({ api_key: "stk_stored" }),
|
|
107
|
+
);
|
|
108
|
+
expect(await provider()).toBe("stk_stored");
|
|
109
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns null on a selfhost backend with no stored key", async () => {
|
|
113
|
+
const provider = createRefreshingTokenProvider(selfhostConfig());
|
|
114
|
+
expect(await provider()).toBeNull();
|
|
65
115
|
});
|
|
66
116
|
|
|
67
117
|
it("falls back to the existing token when refresh fails", async () => {
|
|
68
118
|
mockFetch({ ok: false, status: 401, body: { error: "invalid_grant" } });
|
|
69
119
|
const provider = createRefreshingTokenProvider(
|
|
70
|
-
cloudConfig({
|
|
120
|
+
cloudConfig({
|
|
121
|
+
token: "expired",
|
|
122
|
+
token_expiry: past(),
|
|
123
|
+
refresh_token: "r1",
|
|
124
|
+
}),
|
|
71
125
|
);
|
|
72
126
|
expect(await provider()).toBe("expired");
|
|
73
127
|
});
|
|
@@ -80,7 +134,9 @@ describe("createRefreshingTokenProvider", () => {
|
|
|
80
134
|
it("treats a token without an expiry as valid (legacy)", async () => {
|
|
81
135
|
const fetchSpy = vi.fn();
|
|
82
136
|
vi.stubGlobal("fetch", fetchSpy);
|
|
83
|
-
const provider = createRefreshingTokenProvider(
|
|
137
|
+
const provider = createRefreshingTokenProvider(
|
|
138
|
+
cloudConfig({ token: "legacy" }),
|
|
139
|
+
);
|
|
84
140
|
expect(await provider()).toBe("legacy");
|
|
85
141
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
86
142
|
});
|
package/src/auth/token.ts
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
// expired access token, and expose a token provider that refreshes silently.
|
|
3
3
|
|
|
4
4
|
import type { TokenProvider } from "@stigmer/sdk";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
type Config,
|
|
7
|
+
type NamedBackendConfig,
|
|
8
|
+
activeBackend,
|
|
9
|
+
save,
|
|
10
|
+
} from "../config/index.js";
|
|
6
11
|
import { CliExitError, ExitCode } from "../errors/index.js";
|
|
7
12
|
import { CLIENT_ID, callbackUrl, TOKEN_URL } from "./auth0.js";
|
|
8
13
|
|
|
@@ -28,7 +33,10 @@ interface Auth0TokenResponse {
|
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
/** Exchange a PKCE authorization code for an access (+refresh) token. */
|
|
31
|
-
export async function exchangeCode(params: {
|
|
36
|
+
export async function exchangeCode(params: {
|
|
37
|
+
code: string;
|
|
38
|
+
codeVerifier: string;
|
|
39
|
+
}): Promise<TokenSet> {
|
|
32
40
|
return postToken({
|
|
33
41
|
grant_type: "authorization_code",
|
|
34
42
|
client_id: CLIENT_ID,
|
|
@@ -39,7 +47,9 @@ export async function exchangeCode(params: { code: string; codeVerifier: string
|
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
/** Exchange a refresh token for a fresh access token. */
|
|
42
|
-
export async function refreshAccessToken(
|
|
50
|
+
export async function refreshAccessToken(
|
|
51
|
+
refreshToken: string,
|
|
52
|
+
): Promise<TokenSet> {
|
|
43
53
|
return postToken({
|
|
44
54
|
grant_type: "refresh_token",
|
|
45
55
|
client_id: CLIENT_ID,
|
|
@@ -50,12 +60,17 @@ export async function refreshAccessToken(refreshToken: string): Promise<TokenSet
|
|
|
50
60
|
/**
|
|
51
61
|
* A token provider for the backend client that keeps the access token fresh.
|
|
52
62
|
*
|
|
53
|
-
* Precedence and behavior, evaluated per request
|
|
63
|
+
* Precedence and behavior, evaluated per request against the ACTIVE named
|
|
64
|
+
* backend:
|
|
54
65
|
* 1. STIGMER_API_KEY (env / bridged --api-key) wins and is never refreshed.
|
|
55
|
-
* 2.
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
66
|
+
* 2. selfhost backends return their stored api_key (no refresh machinery —
|
|
67
|
+
* server-minted `stk_` tokens are opaque and live until deleted or
|
|
68
|
+
* expired server-side); local returns null (no auth).
|
|
69
|
+
* 3. A cloud backend's non-expired access token is returned as-is.
|
|
70
|
+
* 4. An expired token with a refresh token is refreshed; the new token,
|
|
71
|
+
* rotated refresh token, and expiry are persisted INTO THE ENTRY, then
|
|
72
|
+
* returned.
|
|
73
|
+
* 5. Otherwise the (possibly stale) token or null is returned, letting the
|
|
59
74
|
* server reject it so the error maps to a clear "please log in" exit.
|
|
60
75
|
*
|
|
61
76
|
* Closes over `config` so a refresh updates the same object the rest of the
|
|
@@ -66,30 +81,38 @@ export function createRefreshingTokenProvider(config: Config): TokenProvider {
|
|
|
66
81
|
const envKey = process.env.STIGMER_API_KEY;
|
|
67
82
|
if (envKey !== undefined && envKey !== "") return envKey;
|
|
68
83
|
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
71
|
-
|
|
84
|
+
const { entry } = activeBackend(config);
|
|
85
|
+
if (entry === undefined) return null;
|
|
86
|
+
if (entry.type === "selfhost") return entry.api_key ?? null;
|
|
87
|
+
|
|
88
|
+
if (entry.token === undefined || entry.token === "") {
|
|
89
|
+
return entry.refresh_token
|
|
90
|
+
? refreshAndPersist(config, entry, entry.refresh_token)
|
|
91
|
+
: null;
|
|
72
92
|
}
|
|
73
|
-
if (!isExpired(
|
|
74
|
-
if (
|
|
75
|
-
|
|
93
|
+
if (!isExpired(entry.token_expiry)) return entry.token;
|
|
94
|
+
if (entry.refresh_token === undefined || entry.refresh_token === "")
|
|
95
|
+
return entry.token;
|
|
96
|
+
return refreshAndPersist(config, entry, entry.refresh_token);
|
|
76
97
|
};
|
|
77
98
|
}
|
|
78
99
|
|
|
79
|
-
async function refreshAndPersist(
|
|
100
|
+
async function refreshAndPersist(
|
|
101
|
+
config: Config,
|
|
102
|
+
entry: NamedBackendConfig,
|
|
103
|
+
refreshToken: string,
|
|
104
|
+
): Promise<string | null> {
|
|
80
105
|
let next: TokenSet;
|
|
81
106
|
try {
|
|
82
107
|
next = await refreshAccessToken(refreshToken);
|
|
83
108
|
} catch {
|
|
84
109
|
// Refresh failed (revoked/expired refresh token). Fall back to the current
|
|
85
110
|
// token if any; the server will reject it and the user is prompted to log in.
|
|
86
|
-
return
|
|
111
|
+
return entry.token ?? null;
|
|
87
112
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
cloud.token_expiry = next.expiresAt;
|
|
92
|
-
config.backend.cloud = cloud;
|
|
113
|
+
entry.token = next.accessToken;
|
|
114
|
+
if (next.refreshToken !== undefined) entry.refresh_token = next.refreshToken;
|
|
115
|
+
entry.token_expiry = next.expiresAt;
|
|
93
116
|
save(config);
|
|
94
117
|
return next.accessToken;
|
|
95
118
|
}
|
|
@@ -120,7 +143,8 @@ async function postToken(form: Record<string, string>): Promise<TokenSet> {
|
|
|
120
143
|
|
|
121
144
|
const body = (await response.json().catch(() => ({}))) as Auth0TokenResponse;
|
|
122
145
|
if (!response.ok || body.access_token === undefined) {
|
|
123
|
-
const detail =
|
|
146
|
+
const detail =
|
|
147
|
+
body.error_description ?? body.error ?? `HTTP ${response.status}`;
|
|
124
148
|
throw new CliExitError(`authentication failed: ${detail}`, ExitCode.Auth);
|
|
125
149
|
}
|
|
126
150
|
|
|
@@ -4,7 +4,13 @@ import type { Config } from "../config/index.js";
|
|
|
4
4
|
import { createBackendClient } from "./client.js";
|
|
5
5
|
|
|
6
6
|
function cloud(token?: string): Config {
|
|
7
|
-
return {
|
|
7
|
+
return {
|
|
8
|
+
backend: { type: "cloud" },
|
|
9
|
+
backends: {
|
|
10
|
+
cloud: { type: "cloud", endpoint: "api.stigmer.ai:443", token },
|
|
11
|
+
},
|
|
12
|
+
current_backend: "cloud",
|
|
13
|
+
};
|
|
8
14
|
}
|
|
9
15
|
|
|
10
16
|
afterEach(() => {
|
|
@@ -19,7 +25,9 @@ describe("createBackendClient — endpoint normalization", () => {
|
|
|
19
25
|
});
|
|
20
26
|
|
|
21
27
|
it("normalizes the local endpoint to a plaintext base URL", () => {
|
|
22
|
-
const client = createBackendClient({
|
|
28
|
+
const client = createBackendClient({
|
|
29
|
+
config: { backend: { type: "local" } },
|
|
30
|
+
});
|
|
23
31
|
expect(client.stigmer.baseUrl).toBe("http://localhost:7234");
|
|
24
32
|
});
|
|
25
33
|
});
|
|
@@ -6,11 +6,22 @@
|
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
7
|
import { create } from "@bufbuild/protobuf";
|
|
8
8
|
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
type ApiKey,
|
|
11
|
+
ApiKeySchema,
|
|
12
|
+
} from "@stigmer/protos/ai/stigmer/iam/apikey/v1/api_pb";
|
|
10
13
|
import { ApiKeyHashSchema } from "@stigmer/protos/ai/stigmer/iam/apikey/v1/io_pb";
|
|
11
14
|
import type { Command } from "commander";
|
|
12
|
-
import {
|
|
13
|
-
|
|
15
|
+
import {
|
|
16
|
+
ensureAuthenticated,
|
|
17
|
+
resolveContextOrganization,
|
|
18
|
+
} from "../../config/index.js";
|
|
19
|
+
import {
|
|
20
|
+
type OutputFlags,
|
|
21
|
+
type OutputFormat,
|
|
22
|
+
renderProtoJson,
|
|
23
|
+
renderProtoYaml,
|
|
24
|
+
} from "../../output/index.js";
|
|
14
25
|
import { addReadFlags, readFormat } from "../shared.js";
|
|
15
26
|
import { parseExpiration } from "./duration.js";
|
|
16
27
|
|
|
@@ -23,7 +34,9 @@ interface ApiKeyCreateFlags extends OutputFlags {
|
|
|
23
34
|
}
|
|
24
35
|
|
|
25
36
|
export function registerApiKey(program: Command): void {
|
|
26
|
-
const apikey = program
|
|
37
|
+
const apikey = program
|
|
38
|
+
.command("apikey")
|
|
39
|
+
.description("manage API keys for Stigmer Cloud authentication");
|
|
27
40
|
|
|
28
41
|
const create = apikey
|
|
29
42
|
.command("create")
|
|
@@ -71,14 +84,23 @@ async function runCreate(options: ApiKeyCreateFlags): Promise<void> {
|
|
|
71
84
|
process.stdout.write(renderCreatedBanner(created));
|
|
72
85
|
}
|
|
73
86
|
|
|
74
|
-
async function runFingerprint(
|
|
75
|
-
|
|
87
|
+
async function runFingerprint(
|
|
88
|
+
rawKey: string,
|
|
89
|
+
options: OutputFlags,
|
|
90
|
+
): Promise<void> {
|
|
91
|
+
// Base64URL without padding — the server's storage encoding (the Java
|
|
92
|
+
// ApiKeyHasher and the TS keymaterial module agree). This was hex until
|
|
93
|
+
// O3, which meant the computed hash could never match a stored key_hash
|
|
94
|
+
// and the lookup below always answered NotFound (gate ruling Q7).
|
|
95
|
+
const hash = createHash("sha256").update(rawKey).digest("base64url");
|
|
76
96
|
|
|
77
97
|
const { connectBackend } = await import("../../backend.js");
|
|
78
98
|
const client = connectBackend();
|
|
79
99
|
ensureAuthenticated(client.config);
|
|
80
100
|
|
|
81
|
-
const key = await client.stigmer.apiKey.getByKeyHash(
|
|
101
|
+
const key = await client.stigmer.apiKey.getByKeyHash(
|
|
102
|
+
create(ApiKeyHashSchema, { value: hash }),
|
|
103
|
+
);
|
|
82
104
|
renderApiKey(key, readFormat(options));
|
|
83
105
|
}
|
|
84
106
|
|
|
@@ -101,7 +123,8 @@ function renderApiKey(key: ApiKey, format: OutputFormat): void {
|
|
|
101
123
|
}
|
|
102
124
|
const lines = [`API Key: ${key.metadata?.id ?? ""}`, ""];
|
|
103
125
|
if (key.metadata?.name) lines.push(` Name: ${key.metadata.name}`);
|
|
104
|
-
if (key.spec?.fingerprint)
|
|
126
|
+
if (key.spec?.fingerprint)
|
|
127
|
+
lines.push(` Fingerprint: ***${key.spec.fingerprint}`);
|
|
105
128
|
lines.push(` Expires: ${formatExpiry(key)}`);
|
|
106
129
|
process.stdout.write(`${lines.join("\n")}\n`);
|
|
107
130
|
}
|
|
@@ -121,13 +144,21 @@ function renderCreatedBanner(key: ApiKey): string {
|
|
|
121
144
|
` ID: ${key.metadata?.id ?? ""}`,
|
|
122
145
|
];
|
|
123
146
|
if (key.metadata?.name) lines.push(` Name: ${key.metadata.name}`);
|
|
124
|
-
if (key.spec?.fingerprint)
|
|
125
|
-
|
|
147
|
+
if (key.spec?.fingerprint)
|
|
148
|
+
lines.push(` Fingerprint: ***${key.spec.fingerprint}`);
|
|
149
|
+
lines.push(
|
|
150
|
+
` Expires: ${formatExpiry(key)}`,
|
|
151
|
+
"",
|
|
152
|
+
"Usage:",
|
|
153
|
+
` export STIGMER_API_KEY='${key.spec?.keyHash ?? ""}'`,
|
|
154
|
+
"",
|
|
155
|
+
);
|
|
126
156
|
return `${lines.join("\n")}\n`;
|
|
127
157
|
}
|
|
128
158
|
|
|
129
159
|
function formatExpiry(key: ApiKey): string {
|
|
130
160
|
if (key.spec?.neverExpires) return "Never";
|
|
131
|
-
if (key.spec?.expiresAt !== undefined)
|
|
161
|
+
if (key.spec?.expiresAt !== undefined)
|
|
162
|
+
return timestampDate(key.spec.expiresAt).toISOString();
|
|
132
163
|
return "Never";
|
|
133
164
|
}
|
|
@@ -4,12 +4,24 @@
|
|
|
4
4
|
// the standard mutating-output flags.
|
|
5
5
|
|
|
6
6
|
import type { Command } from "commander";
|
|
7
|
-
import {
|
|
8
|
-
|
|
7
|
+
import {
|
|
8
|
+
activeBackend,
|
|
9
|
+
ensureAuthenticated,
|
|
10
|
+
load,
|
|
11
|
+
resolveContextOrganization,
|
|
12
|
+
save,
|
|
13
|
+
} from "../../config/index.js";
|
|
14
|
+
import {
|
|
15
|
+
CommandResult,
|
|
16
|
+
type OutputFlags,
|
|
17
|
+
renderResult,
|
|
18
|
+
} from "../../output/index.js";
|
|
9
19
|
import { addResultFlags, resultFormat } from "../shared.js";
|
|
10
20
|
|
|
11
21
|
export function registerAuth(program: Command): void {
|
|
12
|
-
const auth = program
|
|
22
|
+
const auth = program
|
|
23
|
+
.command("auth")
|
|
24
|
+
.description("manage authentication with Stigmer Cloud");
|
|
13
25
|
|
|
14
26
|
auth
|
|
15
27
|
.command("login")
|
|
@@ -44,15 +56,32 @@ export function registerAuth(program: Command): void {
|
|
|
44
56
|
|
|
45
57
|
function runLogout(): CommandResult {
|
|
46
58
|
const config = load();
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
|
|
59
|
+
const { name, entry } = activeBackend(config);
|
|
60
|
+
if (entry?.type === "selfhost") {
|
|
61
|
+
if (entry.api_key === undefined) {
|
|
62
|
+
return CommandResult.warning("No API key stored for this backend");
|
|
63
|
+
}
|
|
64
|
+
entry.api_key = undefined;
|
|
65
|
+
save(config);
|
|
66
|
+
return CommandResult.success(
|
|
67
|
+
`Cleared the stored API key for backend "${name}"`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (
|
|
71
|
+
entry === undefined ||
|
|
72
|
+
(entry.token === undefined && entry.refresh_token === undefined)
|
|
73
|
+
) {
|
|
74
|
+
return CommandResult.warning("Not currently logged in").hint(
|
|
75
|
+
"Run 'stigmer auth login' to authenticate.",
|
|
76
|
+
);
|
|
50
77
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
78
|
+
entry.token = undefined;
|
|
79
|
+
entry.refresh_token = undefined;
|
|
80
|
+
entry.token_expiry = undefined;
|
|
54
81
|
save(config);
|
|
55
|
-
return CommandResult.success("Logged out from Stigmer Cloud").hint(
|
|
82
|
+
return CommandResult.success("Logged out from Stigmer Cloud").hint(
|
|
83
|
+
"Run 'stigmer auth login' to authenticate again.",
|
|
84
|
+
);
|
|
56
85
|
}
|
|
57
86
|
|
|
58
87
|
async function runWhoami(): Promise<CommandResult> {
|
|
@@ -66,21 +95,30 @@ async function runWhoami(): Promise<CommandResult> {
|
|
|
66
95
|
|
|
67
96
|
if (account.metadata !== undefined) {
|
|
68
97
|
section.field("Account ID", account.metadata.id);
|
|
69
|
-
if (account.metadata.name !== "")
|
|
98
|
+
if (account.metadata.name !== "")
|
|
99
|
+
section.field("Name", account.metadata.name);
|
|
70
100
|
}
|
|
71
101
|
if (account.spec !== undefined) {
|
|
72
102
|
if (account.spec.email !== "") section.field("Email", account.spec.email);
|
|
73
103
|
if (account.spec.firstName !== "" || account.spec.lastName !== "") {
|
|
74
|
-
section.field(
|
|
104
|
+
section.field(
|
|
105
|
+
"Full Name",
|
|
106
|
+
`${account.spec.firstName} ${account.spec.lastName}`.trim(),
|
|
107
|
+
);
|
|
75
108
|
}
|
|
76
|
-
section.field(
|
|
109
|
+
section.field(
|
|
110
|
+
"Account Type",
|
|
111
|
+
account.spec.isMachineAccount ? "Machine Account" : "User Account",
|
|
112
|
+
);
|
|
77
113
|
}
|
|
78
114
|
|
|
79
115
|
const org = resolveContextOrganization(client.config);
|
|
80
116
|
if (org !== "") {
|
|
81
117
|
section.field("Organization", org);
|
|
82
118
|
} else {
|
|
83
|
-
result.hint(
|
|
119
|
+
result.hint(
|
|
120
|
+
"No organization set. Use: stigmer config context set --org <slug>",
|
|
121
|
+
);
|
|
84
122
|
}
|
|
85
123
|
|
|
86
124
|
return result;
|