@elixpo/lixblogs-cli 1.1.2
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/API.md +86 -0
- package/LICENSE +21 -0
- package/README.md +157 -0
- package/THREAT_MODEL.md +91 -0
- package/bin/lixblogs.mjs +490 -0
- package/package.json +71 -0
- package/src/api/BlogClient.js +135 -0
- package/src/auth/AuthProvider.js +90 -0
- package/src/auth/AuthenticatedClient.js +116 -0
- package/src/auth/ElixpoAuthProvider.js +281 -0
- package/src/auth/MockAuthProvider.js +170 -0
- package/src/auth/productionGate.js +44 -0
- package/src/commands/auth/login.js +103 -0
- package/src/commands/auth/logout.js +21 -0
- package/src/commands/auth/profiles.js +27 -0
- package/src/commands/auth/revoke.js +45 -0
- package/src/commands/auth/status.js +33 -0
- package/src/commands/blog/index.js +81 -0
- package/src/commands/blog/input.js +59 -0
- package/src/config/CredentialStore.js +142 -0
- package/src/config/KeychainCredentialStore.js +180 -0
- package/src/config/ProfileRegistry.js +105 -0
- package/src/config/config.js +60 -0
- package/src/config/credentialStoreFactory.js +63 -0
- package/src/config/providerFactory.js +42 -0
- package/src/config/redact.js +74 -0
- package/src/content/markdown.js +68 -0
- package/src/content/validate.js +45 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KeychainCredentialStore — real OS-keychain-backed CredentialStore.
|
|
3
|
+
*
|
|
4
|
+
* Uses @napi-rs/keyring: macOS Keychain, Windows Credential Manager, Linux
|
|
5
|
+
* Secret Service/libsecret. Chosen over `keytar` because keytar is
|
|
6
|
+
* archived/deprecated (last release Feb 2022, no longer maintained);
|
|
7
|
+
* @napi-rs/keyring is the actively maintained equivalent with a similar API.
|
|
8
|
+
*
|
|
9
|
+
* Each profile's credentials are stored as a single JSON-serialized secret
|
|
10
|
+
* under a per-profile keychain entry — one entry per profile, not one
|
|
11
|
+
* entry per token field, so multi-profile isolation (see THREAT_MODEL.md
|
|
12
|
+
* §5) maps directly onto separate keychain entries rather than a shared
|
|
13
|
+
* blob multiple profiles could collide in.
|
|
14
|
+
*
|
|
15
|
+
* This class throws on any underlying failure — it does NOT catch and
|
|
16
|
+
* silently return null/succeed. Per #135's "explicit opt-in fallback"
|
|
17
|
+
* requirement, silent degradation here would be exactly the wrong
|
|
18
|
+
* behavior; the caller (via GatedCredentialStore, see CredentialStore.js)
|
|
19
|
+
* is responsible for catching this and prompting for explicit opt-in.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { Entry } from "@napi-rs/keyring";
|
|
23
|
+
import { CredentialStore } from "./CredentialStore.js";
|
|
24
|
+
|
|
25
|
+
const SERVICE_NAME = "lixblogs-cli";
|
|
26
|
+
const PROBE_PROFILE_ID = "__lixblogs_availability_probe__";
|
|
27
|
+
|
|
28
|
+
function entryFor(profileId) {
|
|
29
|
+
return new Entry(SERVICE_NAME, profileId);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Checks whether the OS keychain backend is actually reachable.
|
|
34
|
+
*
|
|
35
|
+
* IMPORTANT, discovered empirically while building this (not assumed):
|
|
36
|
+
* on at least one backend (headless Linux, no Secret Service running),
|
|
37
|
+
* Entry.getPassword() on a missing entry returns null instead of
|
|
38
|
+
* throwing — even when the backend is completely unreachable. That means
|
|
39
|
+
* get() alone cannot distinguish "nothing stored yet" from "keychain is
|
|
40
|
+
* broken." Entry.setPassword() DOES throw reliably when the backend is
|
|
41
|
+
* unreachable in that same environment, so this probe uses a harmless
|
|
42
|
+
* write+delete round-trip to get a trustworthy signal there, rather than
|
|
43
|
+
* inferring availability from get().
|
|
44
|
+
*
|
|
45
|
+
* KNOWN LIMITATION, also discovered empirically (not theoretical): on at
|
|
46
|
+
* least one WSL setup, a probe call that fails (AccessDenied) can be
|
|
47
|
+
* followed by an unrelated Entry's setPassword() succeeding moments
|
|
48
|
+
* later, even though the probe itself correctly reported unavailability.
|
|
49
|
+
* This matches a documented upstream keyring-rs issue where WSL with
|
|
50
|
+
* systemd enabled has only a Secret Service *session* collection and no
|
|
51
|
+
* *default* collection — the first call against the (missing) default
|
|
52
|
+
* collection fails, but the backend appears to recover or fall back on
|
|
53
|
+
* a subsequent call. Practical implication: this probe is a best-effort
|
|
54
|
+
* signal, not a guarantee — a single probe result should be trusted for
|
|
55
|
+
* the immediate decision (fail vs. proceed) but must not be assumed to
|
|
56
|
+
* hold true for every subsequent call in the same process on every
|
|
57
|
+
* platform. If this proves unreliable enough in practice, a more robust
|
|
58
|
+
* approach (e.g. re-probing before every real operation, or shipping a
|
|
59
|
+
* documented "known platforms" support matrix) should be a follow-up.
|
|
60
|
+
*
|
|
61
|
+
* @returns {Promise<{ available: boolean, error?: string }>}
|
|
62
|
+
*/
|
|
63
|
+
export async function probeKeychainAvailability() {
|
|
64
|
+
const entry = entryFor(PROBE_PROFILE_ID);
|
|
65
|
+
try {
|
|
66
|
+
entry.setPassword("probe");
|
|
67
|
+
entry.deletePassword();
|
|
68
|
+
return { available: true };
|
|
69
|
+
} catch (err) {
|
|
70
|
+
// The underlying native error can include a multi-line Rust stack
|
|
71
|
+
// trace in its message — take only the first line for anything shown
|
|
72
|
+
// to a user; the full message is still available via err if needed
|
|
73
|
+
// for debugging (e.g. --verbose diagnostics, a later issue).
|
|
74
|
+
const firstLine = String(err.message ?? err).split("\n")[0].trim();
|
|
75
|
+
return { available: false, error: firstLine };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class KeychainCredentialStore extends CredentialStore {
|
|
80
|
+
async get(profileId) {
|
|
81
|
+
const entry = entryFor(profileId);
|
|
82
|
+
let raw;
|
|
83
|
+
try {
|
|
84
|
+
raw = entry.getPassword();
|
|
85
|
+
} catch (err) {
|
|
86
|
+
// @napi-rs/keyring throws on some platforms/backends when no entry
|
|
87
|
+
// exists yet — that's a legitimate "not logged in" case, not a
|
|
88
|
+
// keychain-unavailable failure. Distinguish by message rather than
|
|
89
|
+
// swallowing all errors, so real unavailability still propagates.
|
|
90
|
+
if (isNotFoundError(err)) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// On some platforms/backends, a missing entry returns null/undefined
|
|
97
|
+
// rather than throwing (observed behavior, not assumed) — treat that
|
|
98
|
+
// the same as "not logged in," not as a parse error.
|
|
99
|
+
if (raw === null || raw === undefined) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return JSON.parse(raw);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async set(profileId, credentials) {
|
|
107
|
+
const entry = entryFor(profileId);
|
|
108
|
+
entry.setPassword(JSON.stringify(credentials));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async delete(profileId) {
|
|
112
|
+
const entry = entryFor(profileId);
|
|
113
|
+
try {
|
|
114
|
+
entry.deletePassword();
|
|
115
|
+
} catch (err) {
|
|
116
|
+
if (isNotFoundError(err)) {
|
|
117
|
+
return; // deleting something that isn't there is not an error
|
|
118
|
+
}
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async listProfiles() {
|
|
124
|
+
// @napi-rs/keyring has no "list all entries for a service" API — OS
|
|
125
|
+
// keychains generally don't expose enumeration without extra
|
|
126
|
+
// permissions. Tracking known profile IDs is therefore NOT this
|
|
127
|
+
// class's job; see ProfileRegistry (profileRegistry.js) for how the
|
|
128
|
+
// CLI tracks "which profiles have I ever logged into" separately from
|
|
129
|
+
// the keychain itself.
|
|
130
|
+
throw new Error(
|
|
131
|
+
"KeychainCredentialStore.listProfiles is not supported directly — " +
|
|
132
|
+
"use ProfileRegistry to track known profile IDs, then look up each " +
|
|
133
|
+
"one via get()."
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isNotFoundError(err) {
|
|
139
|
+
const message = String(err?.message ?? "");
|
|
140
|
+
return /no such|not found|nosuchkeyring|nosuchitem/i.test(message);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* RegistryBackedKeychainCredentialStore — combines KeychainCredentialStore
|
|
145
|
+
* (actual secret storage) with ProfileRegistry (non-sensitive list of known
|
|
146
|
+
* profile IDs) to satisfy the full CredentialStore interface, including
|
|
147
|
+
* listProfiles(), which the raw keychain store alone cannot support.
|
|
148
|
+
*
|
|
149
|
+
* This is the class CLI wiring should actually construct for real use —
|
|
150
|
+
* KeychainCredentialStore and ProfileRegistry are exported separately
|
|
151
|
+
* mainly so each can be tested/reasoned about independently.
|
|
152
|
+
*/
|
|
153
|
+
export class RegistryBackedKeychainCredentialStore extends CredentialStore {
|
|
154
|
+
/**
|
|
155
|
+
* @param {import("./ProfileRegistry.js").ProfileRegistry} profileRegistry
|
|
156
|
+
*/
|
|
157
|
+
constructor(profileRegistry) {
|
|
158
|
+
super();
|
|
159
|
+
this._keychain = new KeychainCredentialStore();
|
|
160
|
+
this._registry = profileRegistry;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async get(profileId) {
|
|
164
|
+
return this._keychain.get(profileId);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async set(profileId, credentials) {
|
|
168
|
+
await this._keychain.set(profileId, credentials);
|
|
169
|
+
await this._registry.add(profileId); // registry write after secret write succeeds
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async delete(profileId) {
|
|
173
|
+
await this._keychain.delete(profileId);
|
|
174
|
+
await this._registry.remove(profileId);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async listProfiles() {
|
|
178
|
+
return this._registry.list();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProfileRegistry — tracks which profile IDs the user has ever logged into,
|
|
3
|
+
* so the CLI can support `lixblogs auth status` (no profile given, meaning
|
|
4
|
+
* "show me all of them") and profile-switching commands.
|
|
5
|
+
*
|
|
6
|
+
* This deliberately stores NOTHING sensitive — just profile names/IDs, not
|
|
7
|
+
* tokens. It's fine for this to live in a plain config file on disk (not
|
|
8
|
+
* the keychain), since profile *names* aren't a secret; only the
|
|
9
|
+
* credentials tied to them are, and those live in CredentialStore.
|
|
10
|
+
*
|
|
11
|
+
* Rationale for why this exists at all: OS keychains generally don't
|
|
12
|
+
* support "list all entries for this service" without extra permissions
|
|
13
|
+
* (see KeychainCredentialStore.listProfiles, which explicitly throws and
|
|
14
|
+
* points here instead). This registry is what makes listing/switching
|
|
15
|
+
* profiles possible without asking the OS keychain to do something most
|
|
16
|
+
* platforms don't reliably support.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { promises as fs } from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
|
|
23
|
+
function defaultRegistryPath() {
|
|
24
|
+
return path.join(os.homedir(), ".config", "lixblogs", "profiles.json");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class ProfileRegistry {
|
|
28
|
+
/** @param {string} [registryPath] */
|
|
29
|
+
constructor(registryPath = defaultRegistryPath()) {
|
|
30
|
+
this._path = registryPath;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @returns {Promise<string[]>} */
|
|
34
|
+
async list() {
|
|
35
|
+
return (await this._read()).profiles;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async getActive() {
|
|
39
|
+
const data = await this._read();
|
|
40
|
+
return data.activeProfile && data.profiles.includes(data.activeProfile)
|
|
41
|
+
? data.activeProfile
|
|
42
|
+
: data.profiles[0] || null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async setActive(profileId) {
|
|
46
|
+
validateProfileId(profileId);
|
|
47
|
+
const data = await this._read();
|
|
48
|
+
if (!data.profiles.includes(profileId)) {
|
|
49
|
+
throw new Error(`Profile "${profileId}" does not exist. Log in with it first.`);
|
|
50
|
+
}
|
|
51
|
+
await this._write(data.profiles, profileId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async _read() {
|
|
55
|
+
try {
|
|
56
|
+
const raw = await fs.readFile(this._path, "utf8");
|
|
57
|
+
const data = JSON.parse(raw);
|
|
58
|
+
const profiles = Array.isArray(data.profiles)
|
|
59
|
+
? data.profiles.filter((profile) => typeof profile === "string")
|
|
60
|
+
: [];
|
|
61
|
+
return {
|
|
62
|
+
profiles,
|
|
63
|
+
activeProfile: typeof data.activeProfile === "string" ? data.activeProfile : null,
|
|
64
|
+
};
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err.code === "ENOENT") return { profiles: [], activeProfile: null };
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @param {string} profileId */
|
|
72
|
+
async add(profileId) {
|
|
73
|
+
validateProfileId(profileId);
|
|
74
|
+
const data = await this._read();
|
|
75
|
+
const profiles = new Set(data.profiles);
|
|
76
|
+
profiles.add(profileId);
|
|
77
|
+
await this._write([...profiles], data.activeProfile || profileId);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @param {string} profileId */
|
|
81
|
+
async remove(profileId) {
|
|
82
|
+
const data = await this._read();
|
|
83
|
+
const profiles = data.profiles.filter((id) => id !== profileId);
|
|
84
|
+
const activeProfile = data.activeProfile === profileId ? profiles[0] || null : data.activeProfile;
|
|
85
|
+
await this._write(profiles, activeProfile);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async _write(profiles, activeProfile = null) {
|
|
89
|
+
await fs.mkdir(path.dirname(this._path), { recursive: true });
|
|
90
|
+
const temporaryPath = `${this._path}.${process.pid}.tmp`;
|
|
91
|
+
await fs.writeFile(
|
|
92
|
+
temporaryPath,
|
|
93
|
+
JSON.stringify({ activeProfile, profiles }, null, 2),
|
|
94
|
+
{ encoding: "utf8", mode: 0o600 },
|
|
95
|
+
);
|
|
96
|
+
await fs.rename(temporaryPath, this._path);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function validateProfileId(profileId) {
|
|
101
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(profileId || "")) {
|
|
102
|
+
throw new Error("Profile names must be 1-64 characters using letters, numbers, dot, dash, or underscore.");
|
|
103
|
+
}
|
|
104
|
+
return profileId;
|
|
105
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config.js — resolves runtime config for the CLI.
|
|
3
|
+
*
|
|
4
|
+
* Per #135: "Config precedence: flags → environment → named profile →
|
|
5
|
+
* defaults." This module implements that precedence for the small set of
|
|
6
|
+
* values the auth commands need right now (environment, active profile,
|
|
7
|
+
* API base URL placeholder for later). It's deliberately minimal — full
|
|
8
|
+
* config-file/profile-file handling belongs to a later CLI-shell issue,
|
|
9
|
+
* not this one.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const DEFAULTS = {
|
|
13
|
+
environment: "production",
|
|
14
|
+
profile: "default",
|
|
15
|
+
accountsBaseUrl: "https://accounts.elixpo.com",
|
|
16
|
+
apiBaseUrl: "https://blogs.elixpo.com",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const ENVIRONMENT_CLIENTS = {
|
|
20
|
+
development: { clientId: "lixblogs-cli-dev", audience: "localhost" },
|
|
21
|
+
staging: { clientId: "lixblogs-cli-staging", audience: "staging.blogs.elixpo.com" },
|
|
22
|
+
production: { clientId: "lixblogs-cli-prod", audience: "blogs.elixpo.com" },
|
|
23
|
+
test: { clientId: "lixblogs-cli-dev", audience: "localhost" },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {Object} params
|
|
28
|
+
* @param {Object} [params.flags] - parsed CLI flags, e.g. { profile, env }
|
|
29
|
+
* @param {NodeJS.ProcessEnv} [params.env] - defaults to process.env
|
|
30
|
+
* @returns {{ environment: string, profile: string, configAllowsProduction: boolean }}
|
|
31
|
+
*/
|
|
32
|
+
export function resolveConfig({ flags = {}, env = process.env } = {}) {
|
|
33
|
+
const environment =
|
|
34
|
+
flags.env ?? env.LIXBLOGS_ENV ?? DEFAULTS.environment;
|
|
35
|
+
|
|
36
|
+
const profile =
|
|
37
|
+
flags.profile ?? env.LIXBLOGS_PROFILE ?? DEFAULTS.profile;
|
|
38
|
+
|
|
39
|
+
const environmentClient = ENVIRONMENT_CLIENTS[environment] || ENVIRONMENT_CLIENTS.production;
|
|
40
|
+
const authProvider =
|
|
41
|
+
flags.authProvider ??
|
|
42
|
+
env.LIXBLOGS_AUTH_PROVIDER ??
|
|
43
|
+
(environment === "production" ? "elixpo" : "mock");
|
|
44
|
+
const accountsBaseUrl =
|
|
45
|
+
flags.accountsUrl ?? env.LIXBLOGS_ACCOUNTS_URL ?? DEFAULTS.accountsBaseUrl;
|
|
46
|
+
const apiBaseUrl = flags.apiUrl ?? env.LIXBLOGS_API_URL ?? DEFAULTS.apiBaseUrl;
|
|
47
|
+
const clientId = flags.clientId ?? env.LIXBLOGS_CLIENT_ID ?? environmentClient.clientId;
|
|
48
|
+
const audience = flags.audience ?? env.LIXBLOGS_AUDIENCE ?? environmentClient.audience;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
environment,
|
|
52
|
+
profile,
|
|
53
|
+
profileExplicit: flags.profile !== undefined || env.LIXBLOGS_PROFILE !== undefined,
|
|
54
|
+
authProvider,
|
|
55
|
+
accountsBaseUrl,
|
|
56
|
+
apiBaseUrl,
|
|
57
|
+
clientId,
|
|
58
|
+
audience,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* credentialStoreFactory.js
|
|
3
|
+
*
|
|
4
|
+
* Real OS-keychain storage is now wired in (KeychainCredentialStore, via
|
|
5
|
+
* @napi-rs/keyring — see that file for why it was chosen over keytar).
|
|
6
|
+
*
|
|
7
|
+
* Per #135: "require an explicit opt-in fallback when unavailable." This
|
|
8
|
+
* means: if the keychain genuinely can't be used (e.g. no Secret Service
|
|
9
|
+
* running, headless environment), this factory does NOT silently fall
|
|
10
|
+
* back to something less secure. It throws CredentialStoreUnavailableError
|
|
11
|
+
* with a clear message; the CLI entry point is responsible for surfacing
|
|
12
|
+
* that to the user and requiring an explicit --allow-insecure-fallback
|
|
13
|
+
* flag (or equivalent) before constructing InMemoryCredentialStore for
|
|
14
|
+
* real use. There is currently no non-memory fallback implementation
|
|
15
|
+
* (e.g. encrypted file) — InMemoryCredentialStore does not persist between
|
|
16
|
+
* runs, so using it as a "fallback" is only acceptable for local dev/testing,
|
|
17
|
+
* not as a real opt-in fallback for end users. Building a real persistent
|
|
18
|
+
* fallback (e.g. an encrypted-file-backed store) is out of scope here and
|
|
19
|
+
* should be a follow-up if keychain unavailability turns out to be common
|
|
20
|
+
* in practice.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
GatedCredentialStore,
|
|
25
|
+
InMemoryCredentialStore,
|
|
26
|
+
CredentialStoreUnavailableError,
|
|
27
|
+
} from "./CredentialStore.js";
|
|
28
|
+
import {
|
|
29
|
+
RegistryBackedKeychainCredentialStore,
|
|
30
|
+
probeKeychainAvailability,
|
|
31
|
+
} from "./KeychainCredentialStore.js";
|
|
32
|
+
import { ProfileRegistry } from "./ProfileRegistry.js";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {{ allowInsecureFallback?: boolean }} [options]
|
|
36
|
+
* @returns {Promise<import("./CredentialStore.js").CredentialStore>}
|
|
37
|
+
*/
|
|
38
|
+
export async function createCredentialStore({ allowInsecureFallback = false, profileRegistry } = {}) {
|
|
39
|
+
// Proactively probe availability with a real write+delete round-trip
|
|
40
|
+
// rather than relying on get() to surface failures — see
|
|
41
|
+
// probeKeychainAvailability's doc comment for why get() alone is not
|
|
42
|
+
// trustworthy for this on every backend.
|
|
43
|
+
const probe = await probeKeychainAvailability();
|
|
44
|
+
|
|
45
|
+
if (!probe.available) {
|
|
46
|
+
if (!allowInsecureFallback) {
|
|
47
|
+
throw new CredentialStoreUnavailableError(
|
|
48
|
+
`OS keychain is unavailable: ${probe.error}. Re-run with an explicit ` +
|
|
49
|
+
`fallback flag if you want to opt in to a less secure storage method.`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
process.stderr.write(
|
|
53
|
+
`warning: OS keychain unavailable (${probe.error}); using in-memory ` +
|
|
54
|
+
`fallback because --allow-insecure-fallback was passed. Credentials ` +
|
|
55
|
+
`will NOT persist between CLI runs.\n`
|
|
56
|
+
);
|
|
57
|
+
return new InMemoryCredentialStore();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const registry = profileRegistry || new ProfileRegistry();
|
|
61
|
+
const realStore = new RegistryBackedKeychainCredentialStore(registry);
|
|
62
|
+
return new GatedCredentialStore(realStore);
|
|
63
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* providerFactory.js — the single place an AuthProvider gets constructed.
|
|
3
|
+
*
|
|
4
|
+
* Every call site that needs an AuthProvider goes through here, not through
|
|
5
|
+
* `new MockAuthProvider()` directly, so the production gate (see
|
|
6
|
+
* ../auth/productionGate.js) is impossible to bypass by accident. This is
|
|
7
|
+
* intentionally a single narrow chokepoint.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { MockAuthProvider } from "../auth/MockAuthProvider.js";
|
|
11
|
+
import { ElixpoAuthProvider } from "../auth/ElixpoAuthProvider.js";
|
|
12
|
+
import { assertProviderAllowed } from "../auth/productionGate.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {{ environment: string, authProvider: string, accountsBaseUrl?: string, clientId?: string, audience?: string }} config
|
|
16
|
+
* @returns {import("../auth/AuthProvider.js").AuthProvider}
|
|
17
|
+
*/
|
|
18
|
+
export function createAuthProvider(config) {
|
|
19
|
+
if (config.authProvider === "mock" && config.environment === "production") {
|
|
20
|
+
throw new Error("The mock auth provider cannot run in production.");
|
|
21
|
+
}
|
|
22
|
+
if (config.authProvider !== "mock" && config.authProvider !== "elixpo") {
|
|
23
|
+
throw new Error(`Unknown auth provider "${config.authProvider}".`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const provider = config.authProvider === "mock"
|
|
27
|
+
? new MockAuthProvider()
|
|
28
|
+
: new ElixpoAuthProvider({
|
|
29
|
+
accountsBaseUrl: config.accountsBaseUrl,
|
|
30
|
+
clientId: config.clientId,
|
|
31
|
+
audience: config.audience,
|
|
32
|
+
cliVersion: config.cliVersion || "1.1.0",
|
|
33
|
+
fetchImpl: config.fetchImpl,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
assertProviderAllowed({
|
|
37
|
+
providerId: provider.providerId,
|
|
38
|
+
environment: config.environment,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return provider;
|
|
42
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* redact.js — token redaction, used everywhere output could leak a
|
|
3
|
+
* credential: console output, --json output, thrown errors, crash reports.
|
|
4
|
+
*
|
|
5
|
+
* Per #135: "Never print tokens in logs, JSON output, telemetry, or crash
|
|
6
|
+
* reports." This is a hard requirement, tested in redact.test.mjs — not
|
|
7
|
+
* just documented.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const REDACTED = "[REDACTED]";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Recursively walks an object/array and replaces any value under a key
|
|
14
|
+
* matching a known sensitive-field name with REDACTED. Also redacts string
|
|
15
|
+
* values that look like tokens even under an unrecognized key, as a
|
|
16
|
+
* defense-in-depth measure (e.g. someone renames a field and forgets to
|
|
17
|
+
* update this list).
|
|
18
|
+
*/
|
|
19
|
+
const SENSITIVE_KEY_PATTERN = /token|refresh|secret|password|authorization/i;
|
|
20
|
+
|
|
21
|
+
// Matches our mock token shapes (mock-access-*, mock-refresh-*) as well as
|
|
22
|
+
// generic bearer-token-like strings, so redaction isn't solely dependent on
|
|
23
|
+
// key names.
|
|
24
|
+
const TOKEN_LIKE_VALUE_PATTERN = /^(mock-(access|refresh)-|Bearer\s+)\S+/i;
|
|
25
|
+
|
|
26
|
+
export function redactValue(value) {
|
|
27
|
+
if (typeof value === "string" && TOKEN_LIKE_VALUE_PATTERN.test(value)) {
|
|
28
|
+
return REDACTED;
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function redactObject(input) {
|
|
34
|
+
if (Array.isArray(input)) {
|
|
35
|
+
return input.map((item) => redactObject(item));
|
|
36
|
+
}
|
|
37
|
+
if (input && typeof input === "object") {
|
|
38
|
+
const out = {};
|
|
39
|
+
for (const [key, value] of Object.entries(input)) {
|
|
40
|
+
if (SENSITIVE_KEY_PATTERN.test(key)) {
|
|
41
|
+
out[key] = REDACTED;
|
|
42
|
+
} else if (typeof value === "object" && value !== null) {
|
|
43
|
+
out[key] = redactObject(value);
|
|
44
|
+
} else {
|
|
45
|
+
out[key] = redactValue(value);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
return redactValue(input);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Wraps JSON.stringify to redact sensitive fields before serialization.
|
|
55
|
+
* Use this for --json output and any log line, never JSON.stringify directly
|
|
56
|
+
* on data that might contain credentials.
|
|
57
|
+
*/
|
|
58
|
+
export function safeJsonStringify(input, space) {
|
|
59
|
+
return JSON.stringify(redactObject(input), null, space);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Wraps an error for safe display/logging — strips any token-like content
|
|
64
|
+
* from the error message itself, not just structured fields, since error
|
|
65
|
+
* messages are free-form strings that could accidentally interpolate a
|
|
66
|
+
* token (e.g. "failed to refresh mock-refresh-abc123").
|
|
67
|
+
*/
|
|
68
|
+
export function redactErrorMessage(message) {
|
|
69
|
+
if (typeof message !== "string") return message;
|
|
70
|
+
return message.replace(
|
|
71
|
+
/(mock-(access|refresh)-\S+|Bearer\s+\S+)/gi,
|
|
72
|
+
REDACTED
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
function text(value) {
|
|
2
|
+
return [{ type: 'text', text: value }];
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function markdownToBlocks(markdown) {
|
|
6
|
+
const lines = String(markdown || '').replace(/\r\n/g, '\n').split('\n');
|
|
7
|
+
const blocks = [];
|
|
8
|
+
let paragraph = [];
|
|
9
|
+
const flush = () => {
|
|
10
|
+
if (!paragraph.length) return;
|
|
11
|
+
blocks.push({ type: 'paragraph', content: text(paragraph.join(' ').trim()) });
|
|
12
|
+
paragraph = [];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
16
|
+
const line = lines[index];
|
|
17
|
+
const trimmed = line.trim();
|
|
18
|
+
if (!trimmed) { flush(); continue; }
|
|
19
|
+
const fence = trimmed.match(/^```([\w+-]*)/);
|
|
20
|
+
if (fence) {
|
|
21
|
+
flush();
|
|
22
|
+
const code = [];
|
|
23
|
+
index += 1;
|
|
24
|
+
while (index < lines.length && !/^```/.test(lines[index].trim())) code.push(lines[index++]);
|
|
25
|
+
blocks.push(fence[1].toLowerCase() === 'mermaid'
|
|
26
|
+
? { type: 'mermaidBlock', props: { diagram: code.join('\n') } }
|
|
27
|
+
: { type: 'codeBlock', props: { language: fence[1].toLowerCase() }, content: text(code.join('\n')) });
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const heading = trimmed.match(/^(#{1,3})\s+(.+)/);
|
|
31
|
+
if (heading) {
|
|
32
|
+
flush();
|
|
33
|
+
blocks.push({ type: 'heading', props: { level: String(heading[1].length) }, content: text(heading[2]) });
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const bullet = trimmed.match(/^[-*]\s+(.+)/);
|
|
37
|
+
if (bullet) { flush(); blocks.push({ type: 'bulletListItem', content: text(bullet[1]) }); continue; }
|
|
38
|
+
const numbered = trimmed.match(/^\d+\.\s+(.+)/);
|
|
39
|
+
if (numbered) { flush(); blocks.push({ type: 'numberedListItem', content: text(numbered[1]) }); continue; }
|
|
40
|
+
const quote = trimmed.match(/^>\s?(.*)/);
|
|
41
|
+
if (quote) { flush(); blocks.push({ type: 'quote', content: text(quote[1]) }); continue; }
|
|
42
|
+
const image = trimmed.match(/^!\[([^\]]*)\]\((https:\/\/[^)]+)\)$/);
|
|
43
|
+
if (image) { flush(); blocks.push({ type: 'image', props: { url: image[2], caption: image[1] } }); continue; }
|
|
44
|
+
if (/^([-*_])\1{2,}$/.test(trimmed)) { flush(); blocks.push({ type: 'divider' }); continue; }
|
|
45
|
+
paragraph.push(trimmed);
|
|
46
|
+
}
|
|
47
|
+
flush();
|
|
48
|
+
return blocks;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function blockText(block) {
|
|
52
|
+
return (block?.content || []).map((item) => typeof item === 'string' ? item : item?.text || '').join('');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function blocksToMarkdown(blocks) {
|
|
56
|
+
return (blocks || []).map((block) => {
|
|
57
|
+
const value = blockText(block);
|
|
58
|
+
if (block.type === 'heading') return `${'#'.repeat(Number(block.props?.level) || 1)} ${value}`;
|
|
59
|
+
if (block.type === 'bulletListItem') return `- ${value}`;
|
|
60
|
+
if (block.type === 'numberedListItem') return `1. ${value}`;
|
|
61
|
+
if (block.type === 'quote') return `> ${value}`;
|
|
62
|
+
if (block.type === 'codeBlock') return `\`\`\`${block.props?.language || ''}\n${value}\n\`\`\``;
|
|
63
|
+
if (block.type === 'mermaidBlock') return `\`\`\`mermaid\n${block.props?.diagram || ''}\n\`\`\``;
|
|
64
|
+
if (block.type === 'image') return ``;
|
|
65
|
+
if (block.type === 'divider') return '---';
|
|
66
|
+
return value;
|
|
67
|
+
}).join('\n\n');
|
|
68
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const MAX_CONTENT_BYTES = 1_500_000;
|
|
2
|
+
|
|
3
|
+
export function countWords(blocks) {
|
|
4
|
+
const words = [];
|
|
5
|
+
const walk = (items) => {
|
|
6
|
+
for (const block of items || []) {
|
|
7
|
+
for (const item of block?.content || []) {
|
|
8
|
+
const value = typeof item === 'string' ? item : item?.text || '';
|
|
9
|
+
words.push(...value.trim().split(/\s+/).filter(Boolean));
|
|
10
|
+
}
|
|
11
|
+
if (block?.children) walk(block.children);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
walk(blocks);
|
|
15
|
+
return words.length;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function validateBlogInput(input, { publishing = false } = {}) {
|
|
19
|
+
if (input.title !== undefined && (typeof input.title !== 'string' || input.title.length > 300)) {
|
|
20
|
+
throw new Error('Title must be 300 characters or fewer.');
|
|
21
|
+
}
|
|
22
|
+
if (input.subtitle !== undefined && (typeof input.subtitle !== 'string' || input.subtitle.length > 500)) {
|
|
23
|
+
throw new Error('Subtitle must be 500 characters or fewer.');
|
|
24
|
+
}
|
|
25
|
+
if (input.tags !== undefined && (!Array.isArray(input.tags) || input.tags.length > 5)) {
|
|
26
|
+
throw new Error('Use at most five tags.');
|
|
27
|
+
}
|
|
28
|
+
if (input.coverUrl && !/^https:\/\//i.test(input.coverUrl)) {
|
|
29
|
+
throw new Error('Cover URLs must use HTTPS.');
|
|
30
|
+
}
|
|
31
|
+
if (input.publishedAs && input.publishedAs !== 'personal' && !/^org:[^:]+$/.test(input.publishedAs)) {
|
|
32
|
+
throw new Error('Publication must be personal or org:<id>.');
|
|
33
|
+
}
|
|
34
|
+
if (input.content !== undefined) {
|
|
35
|
+
if (!Array.isArray(input.content)) throw new Error('Blog content must be a block array.');
|
|
36
|
+
if (Buffer.byteLength(JSON.stringify(input.content), 'utf8') > MAX_CONTENT_BYTES) {
|
|
37
|
+
throw new Error('Blog content exceeds the 1.5 MB limit.');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (publishing) {
|
|
41
|
+
if (!input.title?.trim()) throw new Error('A title is required before publishing.');
|
|
42
|
+
if (countWords(input.content) < 20) throw new Error('A post needs at least 20 words before publishing.');
|
|
43
|
+
}
|
|
44
|
+
return input;
|
|
45
|
+
}
|