@seliseblocks/cli-os 0.1.5 → 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 +1 -1
- package/dist/commands/deselect.js +11 -21
- package/dist/commands/iam/me.js +4 -1
- package/dist/commands/login.js +32 -1
- package/dist/commands/secrets/save.js +1 -1
- package/dist/commands/use.js +18 -3
- package/dist/index.js +19 -12
- package/dist/lib/api.d.ts +1 -0
- package/dist/lib/api.js +27 -1
- package/dist/lib/auth.d.ts +1 -0
- package/dist/lib/auth.js +51 -0
- package/dist/lib/project-info.js +9 -5
- package/dist/lib/scaffold-web/root-files.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,31 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { stringFlag } from "../lib/args.js";
|
|
2
|
+
import { stopProjectImpersonation } from "../lib/auth.js";
|
|
3
|
+
import { readConfig } from "../lib/config.js";
|
|
4
4
|
import { clearSelectedProject, parseCommand } from "../lib/workspace.js";
|
|
5
5
|
export async function deselectProject(argv) {
|
|
6
6
|
const { flags } = parseCommand(argv);
|
|
7
|
+
const accountName = stringFlag(flags, "account") || undefined;
|
|
7
8
|
const config = await readConfig();
|
|
8
|
-
const
|
|
9
|
-
const account = normalizeAccountName(accountName ?? config.activeAccount);
|
|
10
|
-
const tenantId = await clearSelectedProject();
|
|
9
|
+
const tenantId = config.selectedProject?.tenantId;
|
|
11
10
|
if (!tenantId) {
|
|
12
11
|
console.log("No project is currently selected.");
|
|
13
12
|
return;
|
|
14
13
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
...store.accounts,
|
|
22
|
-
[account]: {
|
|
23
|
-
...store.accounts[account],
|
|
24
|
-
projects: remainingProjects
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
console.log(`Deselected project tenant ${tenantId}.`);
|
|
14
|
+
// Stop-impersonation restores a fresh account refresh token and drops the
|
|
15
|
+
// project's cached token from the store, so there's nothing left to clean
|
|
16
|
+
// up here beyond the selection itself.
|
|
17
|
+
await stopProjectImpersonation(accountName, tenantId);
|
|
18
|
+
await clearSelectedProject();
|
|
19
|
+
console.log(`Deselected project tenant ${tenantId}. Account session restored.`);
|
|
30
20
|
console.log("Run 'blocks use <tenantId>' to select a project again.");
|
|
31
21
|
}
|
package/dist/commands/iam/me.js
CHANGED
|
@@ -4,8 +4,11 @@ import { requestContext } from "../../lib/request-context.js";
|
|
|
4
4
|
import { parseCommand } from "../../lib/workspace.js";
|
|
5
5
|
export async function iamMe(argv = []) {
|
|
6
6
|
const { flags } = parseCommand(argv);
|
|
7
|
+
// The server resets to the root tenant's identity for this endpoint
|
|
8
|
+
// regardless of which token calls it, so the impersonated project session
|
|
9
|
+
// works fine here too -- prefer it when a project is selected.
|
|
7
10
|
const me = await blocksRequest("/iam/v4/iam/me", {
|
|
8
|
-
|
|
11
|
+
preferImpersonatedProjectAuth: true,
|
|
9
12
|
...requestContext(flags)
|
|
10
13
|
});
|
|
11
14
|
writeOutput(me, { ...flags, json: true });
|
package/dist/commands/login.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { parseFlags, stringFlag } from "../lib/args.js";
|
|
2
|
-
import { pollDeviceToken, requestDeviceAuthorization } from "../lib/auth.js";
|
|
2
|
+
import { getImpersonatedProjectSession, pollDeviceToken, requestDeviceAuthorization } from "../lib/auth.js";
|
|
3
3
|
import { getAccountProfile, readConfig, writeConfig } from "../lib/config.js";
|
|
4
4
|
import { openBrowser } from "../lib/open-browser.js";
|
|
5
|
+
import { listProjectGroups } from "../lib/project-info.js";
|
|
5
6
|
import { applyAccountToken } from "../lib/token.js";
|
|
6
7
|
import { readTokenStore, writeTokenStore } from "../lib/token-store.js";
|
|
8
|
+
import { readWorkspaceConfig } from "../lib/workspace.js";
|
|
7
9
|
export async function login(argv) {
|
|
8
10
|
const { flags } = parseFlags(argv);
|
|
9
11
|
const accountOverride = stringFlag(flags, "account");
|
|
@@ -32,4 +34,33 @@ export async function login(argv) {
|
|
|
32
34
|
await writeConfig(next.config);
|
|
33
35
|
await writeTokenStore(next.store);
|
|
34
36
|
console.log("Login done.");
|
|
37
|
+
const workspace = await readWorkspaceConfig();
|
|
38
|
+
const rememberedTenantId = workspace.project?.tenantId ?? next.config.selectedProject?.tenantId;
|
|
39
|
+
if (rememberedTenantId) {
|
|
40
|
+
try {
|
|
41
|
+
await getImpersonatedProjectSession(name, rememberedTenantId);
|
|
42
|
+
console.log(`Re-selected project tenant ${rememberedTenantId}.`);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
console.log(`Could not re-select project tenant ${rememberedTenantId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
46
|
+
console.log("Run 'blocks use <tenantId>' to select a project.");
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const groups = await listProjectGroups(flags);
|
|
52
|
+
const projects = groups.flatMap((group) => group.projects ?? []);
|
|
53
|
+
if (projects.length === 0) {
|
|
54
|
+
console.log("No projects found for this account.");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
console.log("Available projects:");
|
|
58
|
+
for (const project of projects) {
|
|
59
|
+
console.log(` ${project.tenantId ?? "-"} ${project.name ?? "-"} ${project.environment ?? "-"}`);
|
|
60
|
+
}
|
|
61
|
+
console.log("Run 'blocks use <tenantId>' to select one.");
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
console.log(`Could not list projects: ${error instanceof Error ? error.message : String(error)}`);
|
|
65
|
+
}
|
|
35
66
|
}
|
|
@@ -45,7 +45,7 @@ function redactSecret(body) {
|
|
|
45
45
|
return body;
|
|
46
46
|
const redacted = {};
|
|
47
47
|
for (const [key, value] of Object.entries(pairs)) {
|
|
48
|
-
redacted[key] = /secret|password|key
|
|
48
|
+
redacted[key] = /(secret|password|key$)/i.test(key) ? "***" : value;
|
|
49
49
|
}
|
|
50
50
|
return { ...body, keyValuePairs: redacted };
|
|
51
51
|
}
|
package/dist/commands/use.js
CHANGED
|
@@ -1,9 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { stringFlag } from "../lib/args.js";
|
|
2
|
+
import { getImpersonatedProjectSession, stopProjectImpersonation } from "../lib/auth.js";
|
|
3
|
+
import { readConfig } from "../lib/config.js";
|
|
4
|
+
import { parseCommand, saveSelectedProject } from "../lib/workspace.js";
|
|
2
5
|
export async function useProject(argv) {
|
|
3
|
-
const
|
|
6
|
+
const { args, flags } = parseCommand(argv);
|
|
7
|
+
const tenantId = args[0];
|
|
4
8
|
if (!tenantId)
|
|
5
9
|
throw new Error("Missing project tenant id.");
|
|
10
|
+
const accountName = stringFlag(flags, "account") || undefined;
|
|
11
|
+
const config = await readConfig();
|
|
12
|
+
const previousTenantId = config.selectedProject?.tenantId;
|
|
13
|
+
if (previousTenantId && previousTenantId !== tenantId) {
|
|
14
|
+
// Switching projects needs a fresh account refresh token to start the new
|
|
15
|
+
// impersonation -- the one used to start the old impersonation was
|
|
16
|
+
// already consumed by the server, so the old session must be stopped
|
|
17
|
+
// first to get a new one back.
|
|
18
|
+
await stopProjectImpersonation(accountName, previousTenantId);
|
|
19
|
+
}
|
|
6
20
|
await saveSelectedProject(tenantId);
|
|
21
|
+
const project = await getImpersonatedProjectSession(accountName, tenantId);
|
|
7
22
|
console.log(`Selected project tenant ${tenantId}`);
|
|
8
|
-
console.log(
|
|
23
|
+
console.log(`Project session ready for tenant ${project.tenantId}.`);
|
|
9
24
|
}
|
package/dist/index.js
CHANGED
|
@@ -500,7 +500,9 @@ Auth:
|
|
|
500
500
|
Device-code login. Prints a verification URL and user code, opens the
|
|
501
501
|
browser to the verification page when possible so you only need to click
|
|
502
502
|
approve, then polls until the device is authorized; stores account access
|
|
503
|
-
and refresh tokens and auto-refreshes later.
|
|
503
|
+
and refresh tokens and auto-refreshes later. If a project was previously
|
|
504
|
+
selected, re-impersonates it automatically; otherwise lists projects and
|
|
505
|
+
prompts you to run 'blocks use <tenantId>'.
|
|
504
506
|
|
|
505
507
|
blocks auth status [--json]
|
|
506
508
|
Show only whether account/project access and refresh tokens are missing,
|
|
@@ -518,8 +520,9 @@ Auth:
|
|
|
518
520
|
|
|
519
521
|
Projects:
|
|
520
522
|
blocks projects list [--json]
|
|
521
|
-
List accessible Blocks projects via /os/v4/Project/Gets
|
|
522
|
-
|
|
523
|
+
List accessible Blocks projects via /os/v4/Project/Gets. Uses the
|
|
524
|
+
impersonated project session when a project is selected, otherwise the
|
|
525
|
+
account token. Read-only.
|
|
523
526
|
|
|
524
527
|
blocks projects get [tenantId] [--deployment] [--json]
|
|
525
528
|
Read one project from Project/Gets. Uses selected project when tenantId is
|
|
@@ -528,20 +531,24 @@ Projects:
|
|
|
528
531
|
to resolve its target. Read-only.
|
|
529
532
|
|
|
530
533
|
blocks use <project-tenant-id>
|
|
531
|
-
Save the selected project tenant globally and in blocks.json when present
|
|
532
|
-
|
|
534
|
+
Save the selected project tenant globally and in blocks.json when present,
|
|
535
|
+
then immediately impersonate it. If a different project was selected,
|
|
536
|
+
stops that impersonation first to reclaim a fresh account refresh token
|
|
537
|
+
before starting the new one.
|
|
533
538
|
|
|
534
539
|
blocks deselect
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
540
|
+
Stop the active impersonation (restoring a fresh account refresh token),
|
|
541
|
+
then clear the selected project tenant (globally and in blocks.json) and
|
|
542
|
+
drop its cached impersonation token. Run 'blocks use <tenantId>' again to
|
|
543
|
+
reselect and re-impersonate.
|
|
539
544
|
|
|
540
545
|
IAM:
|
|
541
546
|
blocks iam me [--json]
|
|
542
|
-
Read the current user from IAM
|
|
543
|
-
|
|
544
|
-
is
|
|
547
|
+
Read the current user from IAM (bootstrapping/CLI operator identity, not
|
|
548
|
+
a project resource). Uses the impersonated project session when a project
|
|
549
|
+
is selected, otherwise the account token -- the server always resolves
|
|
550
|
+
this to the root identity either way. Every other iam * command below is
|
|
551
|
+
project-scoped: it requires a selected project and calls IAM using an
|
|
545
552
|
impersonated project token only, never the account token.
|
|
546
553
|
|
|
547
554
|
Users (/iam/v4/iam/users*):
|
package/dist/lib/api.d.ts
CHANGED
package/dist/lib/api.js
CHANGED
|
@@ -31,8 +31,26 @@ export async function blocksRequest(path, options = {}) {
|
|
|
31
31
|
if (options.impersonatedProjectAuth) {
|
|
32
32
|
const project = await getImpersonatedProjectSession(options.accountName, options.projectTenantId, { forceRefresh });
|
|
33
33
|
headers.Authorization = `Bearer ${project.accessToken}`;
|
|
34
|
+
// The impersonated token is minted and signed by the root tenant's IdP --
|
|
35
|
+
// its JWKS only exists under the root tenant, so signature validation
|
|
36
|
+
// needs x-blocks-key pointed at root, not the target project. The actual
|
|
37
|
+
// tenant-data scoping comes from a claim already inside the validated
|
|
38
|
+
// token, not from this header.
|
|
34
39
|
headers["x-blocks-key"] = project.accountTenant;
|
|
35
40
|
}
|
|
41
|
+
if (options.preferImpersonatedProjectAuth) {
|
|
42
|
+
const tenantId = options.projectTenantId ?? config.selectedProject?.tenantId;
|
|
43
|
+
if (tenantId) {
|
|
44
|
+
const project = await getImpersonatedProjectSession(options.accountName, tenantId, { forceRefresh });
|
|
45
|
+
headers.Authorization = `Bearer ${project.accessToken}`;
|
|
46
|
+
headers["x-blocks-key"] = project.accountTenant;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
const account = await getAccountSession(options.accountName, { forceRefresh });
|
|
50
|
+
headers.Authorization = `Bearer ${account.accessToken}`;
|
|
51
|
+
headers["x-blocks-key"] = account.accountTenant;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
36
54
|
return fetch(url, {
|
|
37
55
|
body: options.body === undefined ? undefined : isFormData ? options.body : JSON.stringify(options.body),
|
|
38
56
|
headers,
|
|
@@ -41,14 +59,22 @@ export async function blocksRequest(path, options = {}) {
|
|
|
41
59
|
throw new Error(`Blocks API request failed for ${url.origin}${url.pathname}: ${error.message}`);
|
|
42
60
|
});
|
|
43
61
|
};
|
|
62
|
+
const method = options.method ?? (options.body === undefined ? "GET" : "POST");
|
|
44
63
|
let response = await send(false);
|
|
45
|
-
if (response.status === 401 && (options.accountAuth || options.impersonatedProjectAuth)) {
|
|
64
|
+
if (response.status === 401 && (options.accountAuth || options.impersonatedProjectAuth || options.preferImpersonatedProjectAuth)) {
|
|
46
65
|
// The locally cached expiry said the token was still good, but the server
|
|
47
66
|
// rejected it anyway (early revocation, clock skew, forced logout server-side).
|
|
48
67
|
// Force one refresh-and-retry before giving up -- this is what actually
|
|
49
68
|
// prevents a spurious 're-run blocks login' when the refresh token is still valid.
|
|
50
69
|
response = await send(true);
|
|
51
70
|
}
|
|
71
|
+
else if (response.status === 500 && method === "GET") {
|
|
72
|
+
// Some tenant-scoped read endpoints (mfa config, signup-settings) intermittently
|
|
73
|
+
// 500 with a JWKS/kid lookup failure right after impersonation, then succeed on
|
|
74
|
+
// an immediate identical retry once the signing-key cache catches up. Safe to
|
|
75
|
+
// retry blindly here because GET is idempotent.
|
|
76
|
+
response = await send(false);
|
|
77
|
+
}
|
|
52
78
|
const text = await response.text();
|
|
53
79
|
const data = parseJson(text);
|
|
54
80
|
if (!response.ok) {
|
package/dist/lib/auth.d.ts
CHANGED
|
@@ -31,4 +31,5 @@ export declare function pollDeviceToken(profile: AccountProfile, device: DeviceA
|
|
|
31
31
|
export declare function getAccountSession(accountOverride?: string, options?: SessionOptions): Promise<AccountSession>;
|
|
32
32
|
export declare function selectProject(tenantId: string): Promise<void>;
|
|
33
33
|
export declare function getImpersonatedProjectSession(accountOverride?: string, tenantOverride?: string, options?: SessionOptions): Promise<ProjectSession>;
|
|
34
|
+
export declare function stopProjectImpersonation(accountOverride?: string, tenantOverride?: string): Promise<void>;
|
|
34
35
|
export declare function revokeCurrentSession(accountOverride?: string): Promise<void>;
|
package/dist/lib/auth.js
CHANGED
|
@@ -194,6 +194,57 @@ export async function getImpersonatedProjectSession(accountOverride, tenantOverr
|
|
|
194
194
|
await writeTokenStore(next.store);
|
|
195
195
|
return projectSessionFromToken(name, tenantId, next.store.accounts[name].projects[tenantId], account.accountTenant);
|
|
196
196
|
}
|
|
197
|
+
// Ends the active project impersonation and restores a fresh, refreshable
|
|
198
|
+
// account-level session. The IAM server revokes the account refresh token the
|
|
199
|
+
// moment it's used to start an impersonation (see impersonateProject) and
|
|
200
|
+
// only ever hands back a project-scoped one in exchange -- calling
|
|
201
|
+
// '/impersonation/stop' is the only way to get a new account-level refresh
|
|
202
|
+
// token back. No-ops if no project is selected or nothing was ever
|
|
203
|
+
// impersonated for it.
|
|
204
|
+
export async function stopProjectImpersonation(accountOverride, tenantOverride) {
|
|
205
|
+
const config = await readConfig();
|
|
206
|
+
const { name, profile } = getAccountProfile(config, accountOverride);
|
|
207
|
+
const tenantId = tenantOverride ?? config.selectedProject?.tenantId;
|
|
208
|
+
if (!tenantId)
|
|
209
|
+
return;
|
|
210
|
+
const store = await readTokenStore();
|
|
211
|
+
if (!store.accounts[name]?.projects?.[tenantId]?.refreshToken)
|
|
212
|
+
return;
|
|
213
|
+
// The stop endpoint requires a currently-valid bearer token to authenticate
|
|
214
|
+
// the call, so refresh the project session first if it's expiring.
|
|
215
|
+
const project = await getImpersonatedProjectSession(name, tenantId);
|
|
216
|
+
const beforeStop = await readTokenStore();
|
|
217
|
+
const projectToken = beforeStop.accounts[name]?.projects?.[tenantId];
|
|
218
|
+
if (!projectToken?.refreshToken)
|
|
219
|
+
return;
|
|
220
|
+
const refreshed = await postStopImpersonation(profile.apiUrl, project.accessToken, project.accountTenant, projectToken.refreshToken);
|
|
221
|
+
const latestConfig = await readConfig();
|
|
222
|
+
const latestStore = await readTokenStore();
|
|
223
|
+
const next = applyAccountToken(latestConfig, latestStore, name, profile.clientId, refreshed);
|
|
224
|
+
const { [tenantId]: _removed, ...remainingProjects } = next.store.accounts[name]?.projects ?? {};
|
|
225
|
+
next.store.accounts[name] = {
|
|
226
|
+
...next.store.accounts[name],
|
|
227
|
+
projects: remainingProjects
|
|
228
|
+
};
|
|
229
|
+
await writeConfig(next.config);
|
|
230
|
+
await writeTokenStore(next.store);
|
|
231
|
+
}
|
|
232
|
+
async function postStopImpersonation(apiUrl, accessToken, accountTenant, refreshToken) {
|
|
233
|
+
const response = await fetch(new URL("/iam/v4/auth/impersonation/stop", apiUrl), {
|
|
234
|
+
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
235
|
+
headers: {
|
|
236
|
+
Accept: "application/json",
|
|
237
|
+
Authorization: `Bearer ${accessToken}`,
|
|
238
|
+
"Content-Type": "application/json",
|
|
239
|
+
"x-blocks-key": accountTenant
|
|
240
|
+
},
|
|
241
|
+
method: "POST"
|
|
242
|
+
});
|
|
243
|
+
const data = parseJson(await response.text());
|
|
244
|
+
if (response.ok && !data.error)
|
|
245
|
+
return data;
|
|
246
|
+
throw new Error(data.error_description ?? data.error ?? `Stop impersonation failed with HTTP ${response.status}`);
|
|
247
|
+
}
|
|
197
248
|
export async function revokeCurrentSession(accountOverride) {
|
|
198
249
|
const config = await readConfig();
|
|
199
250
|
const store = await readTokenStore();
|
package/dist/lib/project-info.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { blocksRequest } from "./api.js";
|
|
2
2
|
import { requestContext } from "./request-context.js";
|
|
3
3
|
import { selectedProject } from "./workspace.js";
|
|
4
|
-
// Project metadata (Project/Gets, Project/GetAsset)
|
|
5
|
-
//
|
|
6
|
-
// the
|
|
4
|
+
// Project metadata (Project/Gets, Project/GetAsset) is authorized at the
|
|
5
|
+
// account/tenant-group level, above any single project's own API surface --
|
|
6
|
+
// but the platform's permission check rebuilds to the root tenant while
|
|
7
|
+
// impersonating, and the underlying query filters by user id rather than
|
|
8
|
+
// tenant, so the impersonated project session works here too. Prefer it when
|
|
9
|
+
// a project is selected (avoids an extra account-session refresh mid-project
|
|
10
|
+
// work); fall back to the account token when nothing is selected yet.
|
|
7
11
|
export async function listProjectGroups(flags) {
|
|
8
12
|
return blocksRequest("/os/v4/Project/Gets", {
|
|
9
|
-
|
|
13
|
+
preferImpersonatedProjectAuth: true,
|
|
10
14
|
query: { page: 0, pageSize: 100, tenantGroupId: "" },
|
|
11
15
|
...requestContext(flags)
|
|
12
16
|
});
|
|
@@ -32,7 +36,7 @@ export async function resolveSelectedProject(flags) {
|
|
|
32
36
|
}
|
|
33
37
|
export async function getProjectAssets(tenantGroupId, flags) {
|
|
34
38
|
return blocksRequest("/os/v4/Project/GetAsset", {
|
|
35
|
-
|
|
39
|
+
preferImpersonatedProjectAuth: true,
|
|
36
40
|
query: { page: 0, pageSize: 100, tenantGroupId },
|
|
37
41
|
...requestContext(flags)
|
|
38
42
|
});
|
|
@@ -18,7 +18,7 @@ export async function writeRootFiles(root, options) {
|
|
|
18
18
|
},
|
|
19
19
|
dependencies: {
|
|
20
20
|
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
|
21
|
-
"@seliseblocks/client": "^0.1.
|
|
21
|
+
"@seliseblocks/client": "^0.1.3",
|
|
22
22
|
"@tanstack/react-query": "^5.101.4",
|
|
23
23
|
clsx: "^2.1.1",
|
|
24
24
|
"lucide-react": "^1.28.0",
|