everything-dev
Version:
A consolidated product package for building Module Federation apps with oRPC APIs.
222 lines (220 loc) • 9.51 kB
JavaScript
import { colors, icons } from "../utils/theme.mjs";
import { ResolutionSessionService } from "../services/resolution/session.mjs";
import { SiteClient, parseSiteErrorDetail } from "../services/auth/site-client.mjs";
import { SessionStore } from "../services/auth/session-store.mjs";
import { isReservedSecretName, isValidSecretName } from "../services/hosting/secrets.mjs";
import { Effect } from "effect";
import process from "node:process";
import * as p from "@clack/prompts";
//#region src/handlers/secrets.ts
/**
* Resolve the runtime identity + declared secrets from the resolution
* session's config (the authored bos.app.ts in the cwd). The rows the vault
* stores are keyed by this identity; the vault ITSELF is whichever deployment
* answers at the credential's site.
*/
function collectDeclaredSecrets(config) {
const cfg = config;
if (!cfg) return [];
const declared = /* @__PURE__ */ new Set();
for (const secret of cfg.secrets ?? []) declared.add(secret);
for (const section of Object.values(cfg.app ?? {})) for (const secret of section?.secrets ?? []) declared.add(secret);
for (const plugin of Object.values(cfg.plugins ?? {})) for (const secret of plugin?.secrets ?? []) declared.add(secret);
return [...declared];
}
/**
* v1 tier heuristic (plan 055 ticket 09): authoritative tier data arrives with
* the tenant bindings surface (ticket 14). Until then, a target that is
* neither the declared config's own runtime nor a child of the vault site's
* runtime gets an honest warning — a BYO-account runtime supplies its own env,
* so rows stored for it would never be delivered.
*/
function hostingRelationshipWarning(config, siteUrl, account, gateway) {
const cfg = config;
if (cfg?.account === account && cfg?.domain === gateway) return null;
const extendsText = JSON.stringify(cfg?.extends ?? "");
const siteHost = new URL(siteUrl).host;
if (extendsText.includes(siteHost) || extendsText.includes(new URL(siteUrl).hostname)) return null;
return `The target runtime (${account}/${gateway}) is not this config's declared runtime, and its extends chain does not reference the vault site (${siteHost}). If this is a BYO-account runtime, it supplies its own env — these rows will not be delivered to it.`;
}
function registerSecrets(builder) {
return {
secretsSet: builder.secretsSet.effect(function* (_opts, input) {
const session = yield* ResolutionSessionService;
const sessions = yield* SessionStore;
const siteClient = yield* SiteClient;
const configDir = session?.root ?? process.cwd();
const credential = sessions.read(configDir)?.credential;
if (!credential?.apiKey) return {
status: "error",
error: "No CLI credential — run `bos login` first"
};
const siteUrl = input.site ?? credential.siteUrl;
if (!siteUrl) return {
status: "error",
error: "No vault site — pass --site or re-run `bos login`"
};
if (!isValidSecretName(input.name)) return {
status: "error",
error: isReservedSecretName(input.name) ? `Secret name ${input.name} is reserved (BOS_* and PORT are composed by the platform)` : "Secret name must be UPPER_SNAKE_CASE env-var style"
};
const value = input.value ?? (yield* Effect.promise(() => p.password({ message: `Value for ${input.name} (input hidden)` })));
if (p.isCancel(value)) process.exit(0);
if (!value) return {
status: "error",
error: "Empty secret value — nothing stored"
};
const account = session?.config?.account;
const gateway = session?.config?.domain;
if (!account || !gateway) return {
status: "error",
error: "No declared runtime identity — the authored config's account/domain are required"
};
const response = yield* Effect.promise(() => siteClient.rpcPost(siteUrl, "/api/rpc/secrets/set", {
account,
gateway,
name: input.name,
value
}, void 0, { "x-api-key": credential.apiKey }));
if (response.status !== 200) return {
status: "error",
error: parseSiteErrorDetail(response.status, response.json) ?? `Vault refused (${response.status})`
};
return {
status: "set",
name: input.name,
siteUrl,
warning: hostingRelationshipWarning(session?.config, siteUrl, account, gateway) ?? void 0
};
}),
secretsList: builder.secretsList.effect(function* (_opts, input) {
const session = yield* ResolutionSessionService;
const sessions = yield* SessionStore;
const siteClient = yield* SiteClient;
const configDir = session?.root ?? process.cwd();
const credential = sessions.read(configDir)?.credential;
if (!credential?.apiKey) return {
status: "error",
error: "No CLI credential — run `bos login` first"
};
const siteUrl = input.site ?? credential.siteUrl;
if (!siteUrl) return {
status: "error",
error: "No vault site — pass --site or re-run `bos login`"
};
const account = input.account ?? session?.config?.account;
const gateway = input.gateway ?? session?.config?.domain;
if (!account || !gateway) return {
status: "error",
error: "No target runtime — pass --account/--gateway or run from the runtime's repo"
};
const response = yield* Effect.promise(() => siteClient.rpcPost(siteUrl, "/api/rpc/secrets/list", {
account,
gateway,
declared: collectDeclaredSecrets(session?.config)
}, void 0, { "x-api-key": credential.apiKey }));
if (response.status !== 200) return {
status: "error",
error: parseSiteErrorDetail(response.status, response.json) ?? `Vault refused (${response.status})`
};
const body = response.json.json;
return {
status: "listed",
secrets: body?.secrets ?? [],
missing: body?.missing ?? [],
siteUrl,
warning: hostingRelationshipWarning(session?.config, siteUrl, account, gateway) ?? void 0
};
}),
secretsRemove: builder.secretsRemove.effect(function* (_opts, input) {
const session = yield* ResolutionSessionService;
const sessions = yield* SessionStore;
const siteClient = yield* SiteClient;
const configDir = session?.root ?? process.cwd();
const credential = sessions.read(configDir)?.credential;
if (!credential?.apiKey) return {
status: "error",
error: "No CLI credential — run `bos login` first"
};
const siteUrl = credential.siteUrl;
if (!siteUrl) return {
status: "error",
error: "No vault site — re-run `bos login`"
};
if (!input.yes) {
const confirmed = yield* Effect.promise(() => p.confirm({ message: `Remove ${input.name} from the vault at ${siteUrl}?` }));
if (p.isCancel(confirmed)) process.exit(0);
if (!confirmed) return {
status: "error",
name: input.name,
error: "Cancelled — nothing removed"
};
}
const account = session?.config?.account;
const gateway = session?.config?.domain;
if (!account || !gateway) return {
status: "error",
error: "No declared runtime identity — the authored config's account/domain are required"
};
const response = yield* Effect.promise(() => siteClient.rpcPost(siteUrl, "/api/rpc/secrets/remove", {
account,
gateway,
name: input.name
}, void 0, { "x-api-key": credential.apiKey }));
if (response.status !== 200) return {
status: "error",
error: parseSiteErrorDetail(response.status, response.json) ?? `Vault refused (${response.status})`
};
return response.json.json?.removed ? {
status: "removed",
name: input.name
} : {
status: "not-found",
name: input.name
};
})
};
}
const secretsRenderers = {
secretsSet: async (result) => {
console.log();
if (result.status === "set") {
console.log(` ${colors.green(`${icons.ok} Secret ${result.name} stored`)}`);
console.log(` ${colors.dim(`Vault: ${result.siteUrl}`)}`);
console.log(` ${colors.dim("The value is sealed at rest and never echoed back")}`);
if (result.warning) console.log(` ${colors.yellow("⚠")} ${result.warning}`);
} else console.log(` ${colors.error(`${icons.err} ${result.error ?? "Failed to store secret"}`)}`);
console.log();
},
secretsList: async (result) => {
console.log();
if (result.status !== "listed") {
console.log(` ${colors.error(`${icons.err} ${result.error ?? "Failed to list secrets"}`)}`);
console.log();
return;
}
console.log(` ${colors.green(`${icons.ok} Secrets`)} ${colors.dim(`vault: ${result.siteUrl}`)}`);
if (result.warning) console.log(` ${colors.yellow("⚠")} ${result.warning}`);
const rows = result.secrets ?? [];
if (rows.length === 0) console.log(` ${colors.dim("No secrets stored and nothing declared")}`);
for (const secret of rows) {
const mark = secret.state === "set" ? colors.green("set") : secret.state === "missing" ? colors.yellow("missing") : colors.dim("undeclared");
console.log(` ${colors.dim(secret.name.padEnd(24))} ${mark}`);
}
if ((result.missing ?? []).length > 0) {
console.log();
console.log(` ${colors.yellow("⚠")} Missing: ${result.missing.join(", ")} — set them with \`bos secrets set NAME\``);
}
console.log();
},
secretsRemove: async (result) => {
console.log();
if (result.status === "removed") console.log(` ${colors.green(`${icons.ok} Removed ${result.name}`)}`);
else if (result.status === "not-found") console.log(` ${colors.yellow("⚠")} ${result.name} was not stored`);
else console.log(` ${colors.error(`${icons.err} ${result.error ?? "Failed to remove"}`)}`);
console.log();
}
};
//#endregion
export { registerSecrets, secretsRenderers };
//# sourceMappingURL=secrets.mjs.map