@datumos/datum-cli 0.0.0 → 0.588.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.
@@ -0,0 +1,356 @@
1
+ /**
2
+ * `datum artifacts publish <dir>` — publishing an artifact from OUTSIDE a
3
+ * run (#589, docs/artifacts.md "Publishing from outside a run").
4
+ *
5
+ * Hand-written, unlike every other API command in this CLI, because it is
6
+ * not one API call: it walks a directory, decides a TRANSPORT and then
7
+ * drives either the one-call shortcut (POST /api/artifacts/publish, small
8
+ * pages travel in the body) or the two-step direct upload (plan → a
9
+ * presigned PUT per file → commit), which is what the runtime does at the
10
+ * end of a run. A flag-based derived command could express neither the
11
+ * walk nor the PUTs, so `POST /api/artifacts/publish` is `x-cli: hidden`
12
+ * in the spec and this command owns the name.
13
+ *
14
+ * What it never does: choose a visibility. A published artifact starts
15
+ * space-private and a human climbs the ladder in the panel (security
16
+ * invariant I1); a new version of an artifact ALREADY shared beyond its
17
+ * space comes back as a `draft` and this command says where it waits.
18
+ */
19
+ import { createHash } from "node:crypto";
20
+ import fs from "node:fs";
21
+ import fsp from "node:fs/promises";
22
+ import path from "node:path";
23
+ import { apiSurface, UsageError } from "./invoke.js";
24
+ /**
25
+ * The server's caps, mirrored here so the CLI can decide the transport
26
+ * before it talks to anyone. The server is the source of truth
27
+ * (`server/src/outputs/outputs.interfaces.ts`,
28
+ * `MAX_INLINE_PUBLICATION_BYTES` / `MAX_OUTPUTS_PER_RUN`); the CLI cannot
29
+ * import from there — the published npm package ships `cli/` and the spec
30
+ * alone — so `artifactsPublish.test.ts` pins these to the server's values.
31
+ */
32
+ export const MAX_INLINE_PUBLICATION_BYTES = 8 * 1024 * 1024;
33
+ export const MAX_PUBLICATION_FILES = 200;
34
+ /** Deepest directory nesting the collector walks (the server's MAX_DEPTH). */
35
+ export const MAX_PUBLICATION_DEPTH = 6;
36
+ /** The manifest rides the request body, never as a file of the publication. */
37
+ export const PUBLICATION_MANIFEST_NAME = "manifest.json";
38
+ /** The server's `ARTIFACT_KEY_RE` (outputs.interfaces.ts), mirrored like the caps. */
39
+ export const PUBLICATION_KEY_RE = /^[A-Za-z0-9._:/-]{1,120}$/;
40
+ const line = (s, text) => s.write(`${text}\n`);
41
+ /**
42
+ * The publication's files, by the SAME rules the collector applies to a
43
+ * run's outputs/ directory: no dotfiles, no `node_modules`, no symlinks,
44
+ * no empty files, at most 6 levels deep, at most 200 files. The manifest
45
+ * is not a file of the publication — it rides the request body.
46
+ */
47
+ export async function walkPublication(dir, depth = 0, root = dir) {
48
+ if (depth > MAX_PUBLICATION_DEPTH)
49
+ return [];
50
+ let entries;
51
+ try {
52
+ entries = await fsp.readdir(dir, { withFileTypes: true });
53
+ }
54
+ catch (err) {
55
+ const code = err.code;
56
+ if (depth === 0 && (code === "ENOENT" || code === "ENOTDIR")) {
57
+ throw new UsageError(`${dir} is not a directory`);
58
+ }
59
+ throw err;
60
+ }
61
+ entries.sort((a, b) => a.name.localeCompare(b.name));
62
+ const out = [];
63
+ for (const entry of entries) {
64
+ if (entry.name.startsWith("."))
65
+ continue;
66
+ if (entry.name === "node_modules")
67
+ continue;
68
+ const abs = path.join(dir, entry.name);
69
+ if (entry.isSymbolicLink())
70
+ continue;
71
+ if (entry.isDirectory()) {
72
+ out.push(...(await walkPublication(abs, depth + 1, root)));
73
+ continue;
74
+ }
75
+ if (!entry.isFile())
76
+ continue;
77
+ const rel = path.relative(root, abs).split(path.sep).join("/");
78
+ if (rel === PUBLICATION_MANIFEST_NAME)
79
+ continue;
80
+ const stat = await fsp.stat(abs);
81
+ if (stat.size === 0)
82
+ continue;
83
+ out.push({ rel, abs, size: stat.size, sha256: await hashFile(abs) });
84
+ }
85
+ return out;
86
+ }
87
+ async function hashFile(abs) {
88
+ const hash = createHash("sha256");
89
+ for await (const chunk of fs.createReadStream(abs)) {
90
+ hash.update(chunk);
91
+ }
92
+ return hash.digest("hex");
93
+ }
94
+ /** `<dir>/manifest.json`, when it is there and parses; else nothing. */
95
+ export async function readPublicationManifest(dir) {
96
+ let text;
97
+ try {
98
+ text = await fsp.readFile(path.join(dir, PUBLICATION_MANIFEST_NAME), "utf8");
99
+ }
100
+ catch {
101
+ return {};
102
+ }
103
+ let parsed;
104
+ try {
105
+ parsed = JSON.parse(text);
106
+ }
107
+ catch {
108
+ throw new UsageError(`${path.join(dir, PUBLICATION_MANIFEST_NAME)} is not valid JSON — fix it or pass --key/--title instead`);
109
+ }
110
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
111
+ throw new UsageError(`${path.join(dir, PUBLICATION_MANIFEST_NAME)} must be a JSON object { key, title, description, entry }`);
112
+ }
113
+ const doc = parsed;
114
+ const pick = (name) => typeof doc[name] === "string" && doc[name] !== "" ? { [name]: doc[name] } : {};
115
+ return {
116
+ ...pick("key"),
117
+ ...pick("title"),
118
+ ...pick("description"),
119
+ ...pick("entry"),
120
+ ...pick("kind"),
121
+ // #2883: dropping the rules would publish the page under the defaults.
122
+ ...(doc.state !== undefined ? { state: doc.state } : {}),
123
+ };
124
+ }
125
+ /** The manifest as the request will carry it — the flags win over the file. */
126
+ export function mergeManifest(manifest, flags) {
127
+ const merged = { ...manifest };
128
+ for (const name of ["key", "title", "description", "entry"]) {
129
+ const value = flags[name];
130
+ if (value !== undefined)
131
+ merged[name] = value;
132
+ }
133
+ return merged;
134
+ }
135
+ /**
136
+ * Which door: the whole set in one request body while it fits the inline
137
+ * cap, the two-step direct upload above it. The plan/commit path exists
138
+ * because the platform's public edge cuts bodies of a few MB (#556) — an
139
+ * artifact may carry hundreds of megabytes of assets.
140
+ *
141
+ * The cap is measured on what actually travels: the SERIALIZED body, JSON
142
+ * envelope and base64 padding included. Binary assets grow a third on the
143
+ * way in, so a set of raw bytes under the cap can still be a request over
144
+ * it — deciding on the raw sum would send it inline and earn a 413.
145
+ */
146
+ export function transportFor(bodyBytes) {
147
+ return bodyBytes <= MAX_INLINE_PUBLICATION_BYTES ? "inline" : "direct";
148
+ }
149
+ /** What the inline body weighs on the wire. */
150
+ export function serializedBodyBytes(body) {
151
+ return Buffer.byteLength(JSON.stringify(body));
152
+ }
153
+ /**
154
+ * The cheap half of the decision: the raw bytes are a LOWER bound on the
155
+ * serialized body (JSON and base64 only ever add), so a set already over
156
+ * the cap goes direct without being read into memory at all.
157
+ */
158
+ export function exceedsInlineCapUnencoded(files) {
159
+ return files.reduce((n, f) => n + f.size, 0) > MAX_INLINE_PUBLICATION_BYTES;
160
+ }
161
+ /** The inline body: text files as UTF-8, everything else base64. */
162
+ export async function inlinePublicationBody(fields, files) {
163
+ const out = [];
164
+ for (const file of files) {
165
+ const bytes = await fsp.readFile(file.abs);
166
+ // Bytes that survive a UTF-8 round trip travel as text — a page reads
167
+ // as itself in the request and the body stays a third smaller.
168
+ const text = bytes.toString("utf8");
169
+ out.push(Buffer.from(text, "utf8").equals(bytes)
170
+ ? { path: file.rel, content: text }
171
+ : { path: file.rel, contentBase64: bytes.toString("base64") });
172
+ }
173
+ return { ...fields, files: out };
174
+ }
175
+ /** The declaration both plan and commit send. */
176
+ export function declarationOf(files) {
177
+ return files.map((f) => ({ path: f.rel, size: f.size, sha256: f.sha256 }));
178
+ }
179
+ /**
180
+ * The command. Exit 0 on a recorded version, 1 on a refusal (the server's
181
+ * `error` is printed as it came), 2 on a usage problem.
182
+ */
183
+ export async function runArtifactsPublish(args, config, io) {
184
+ const doFetch = io.fetchImpl ?? fetch;
185
+ let surface;
186
+ let files;
187
+ let fields;
188
+ try {
189
+ surface = apiSurface(config);
190
+ files = await walkPublication(args.dir);
191
+ if (files.length === 0) {
192
+ throw new UsageError(`${args.dir} holds nothing to publish — a page (.html or .md) plus the assets it references ` +
193
+ `(dotfiles, node_modules, symlinks and empty files are never collected)`);
194
+ }
195
+ if (files.length > MAX_PUBLICATION_FILES) {
196
+ throw new UsageError(`${args.dir} holds ${files.length} files — a publication takes at most ${MAX_PUBLICATION_FILES}`);
197
+ }
198
+ fields = mergeManifest(await readPublicationManifest(args.dir), {
199
+ ...(args.key !== undefined ? { key: args.key } : {}),
200
+ ...(args.title !== undefined ? { title: args.title } : {}),
201
+ ...(args.description !== undefined ? { description: args.description } : {}),
202
+ ...(args.entry !== undefined ? { entry: args.entry } : {}),
203
+ });
204
+ if (!fields.key) {
205
+ throw new UsageError("a publication needs --key (or a `key` in manifest.json) — it is the artifact's stable " +
206
+ "identity in the space: the same key appends a version to the SAME link");
207
+ }
208
+ // The server's grammar, checked here too: a bad key must not cost a
209
+ // walk, hashes and an upload before the refusal (e2e 2026-09-07).
210
+ if (!PUBLICATION_KEY_RE.test(fields.key)) {
211
+ throw new UsageError(`key "${fields.key}" is not valid — 1–120 characters of letters, digits and \`. _ : / -\``);
212
+ }
213
+ }
214
+ catch (err) {
215
+ if (err instanceof UsageError) {
216
+ line(io.err, `datum: ${err.message}`);
217
+ return 2;
218
+ }
219
+ throw err;
220
+ }
221
+ try {
222
+ // Build the inline body once — it is both the transport decision and,
223
+ // when it fits, the request itself. A set whose raw bytes already
224
+ // exceed the cap never gets built.
225
+ const inlineBody = exceedsInlineCapUnencoded(files)
226
+ ? null
227
+ : await inlinePublicationBody(fields, files);
228
+ const inlineBytes = inlineBody === null ? null : serializedBodyBytes(inlineBody);
229
+ const publication = inlineBody !== null && inlineBytes !== null && transportFor(inlineBytes) === "inline"
230
+ ? await publishInline(inlineBody, surface, doFetch)
231
+ : await publishDirect(fields, files, surface, doFetch, io, inlineBytes);
232
+ report(publication, io);
233
+ // A publication is one set, so a file the server did not take makes the
234
+ // whole command a refusal (#589 review, round 2): the server refuses an
235
+ // incomplete publication with a 409 now, but a client must not read a
236
+ // 200 with `rejected` as success — `datum artifacts publish` in a
237
+ // pipeline has to fail where the page would render broken.
238
+ return (publication.rejected?.length ?? 0) > 0 ? 1 : 0;
239
+ }
240
+ catch (err) {
241
+ if (err instanceof PublishRefused) {
242
+ line(io.err, `datum: ${err.message}`);
243
+ return 1;
244
+ }
245
+ if (err instanceof UsageError) {
246
+ line(io.err, `datum: ${err.message}`);
247
+ return 2;
248
+ }
249
+ throw err;
250
+ }
251
+ }
252
+ /** A refusal from the server (or from the upload), already human-readable. */
253
+ class PublishRefused extends Error {
254
+ }
255
+ async function publishInline(body, surface, doFetch) {
256
+ return callJson(doFetch, surface.url("/api/artifacts/publish"), surface.headers, body);
257
+ }
258
+ async function publishDirect(fields, files, surface, doFetch, io,
259
+ /** What one inline request would have weighed; null = never measured. */
260
+ inlineBytes) {
261
+ const plan = await callJson(doFetch, surface.url("/api/artifacts/publish/plan"), surface.headers, { files: declarationOf(files), ...(fields.key ? { key: fields.key } : {}) });
262
+ if (plan.mode === "tar") {
263
+ // No reachable presigned URLs here (a dev MinIO, or an instance without
264
+ // a public bucket endpoint), and the set is over the inline cap — there
265
+ // is no third transport for a person: say so instead of half-uploading.
266
+ // The weight is the request body's when we got to measure it: a set of
267
+ // raw bytes under the cap is over it once base64-encoded.
268
+ const weight = inlineBytes === null
269
+ ? humanBytes(files.reduce((n, f) => n + f.size, 0))
270
+ : `${humanBytes(inlineBytes)} of request body (${humanBytes(files.reduce((n, f) => n + f.size, 0))} of files, encoded)`;
271
+ throw new PublishRefused(`this instance cannot hand out upload URLs (the plan answered mode "tar"), and ` +
272
+ `${weight} does not fit the inline cap of ` +
273
+ `${humanBytes(MAX_INLINE_PUBLICATION_BYTES)} — publish a smaller set, or run the publication ` +
274
+ `where the platform's file storage is reachable`);
275
+ }
276
+ const byPath = new Map(plan.files.map((f) => [f.path, f]));
277
+ const rejected = plan.files.filter((f) => f.action === "reject");
278
+ if (rejected.length > 0) {
279
+ throw new PublishRefused(`the server refused ${rejected.length} file(s): ` +
280
+ rejected.map((f) => `${f.path} (${f.reason ?? "no reason given"})`).join("; "));
281
+ }
282
+ const send = files.filter((f) => byPath.get(f.rel)?.action === "upload");
283
+ for (const file of send) {
284
+ const entry = byPath.get(file.rel);
285
+ if (!entry.url) {
286
+ throw new PublishRefused(`the plan gave no upload URL for ${file.rel}`);
287
+ }
288
+ const res = await doFetch(entry.url, {
289
+ method: "PUT",
290
+ headers: entry.headers ?? {},
291
+ body: await fsp.readFile(file.abs),
292
+ });
293
+ if (!res.ok) {
294
+ throw new PublishRefused(`uploading ${file.rel} failed with HTTP ${res.status} — the upload URL is short-lived, try again`);
295
+ }
296
+ }
297
+ if (send.length > 0) {
298
+ line(io.err, `Uploaded ${send.length} of ${files.length} file(s); the rest were already stored.`);
299
+ }
300
+ // Commit everything the plan did not skip: the files just uploaded plus
301
+ // the ones this space's bucket already held.
302
+ const commit = files.filter((f) => byPath.get(f.rel)?.action !== "skip");
303
+ return callJson(doFetch, surface.url("/api/artifacts/publish/commit"), surface.headers, { ...fields, files: declarationOf(commit) });
304
+ }
305
+ async function callJson(doFetch, url, headers, body) {
306
+ const res = await doFetch(url, {
307
+ method: "POST",
308
+ headers: { ...headers, "content-type": "application/json", accept: "application/json" },
309
+ body: JSON.stringify(body),
310
+ redirect: "manual",
311
+ });
312
+ const text = await res.text();
313
+ let parsed = {};
314
+ try {
315
+ parsed = JSON.parse(text);
316
+ }
317
+ catch {
318
+ // not JSON — the message below carries the status and a snippet
319
+ }
320
+ if (!res.ok) {
321
+ const detail = typeof parsed.error === "string" ? parsed.error : text.slice(0, 200).trim();
322
+ const code = typeof parsed.code === "string" ? ` [${parsed.code}]` : "";
323
+ throw new PublishRefused(`HTTP ${res.status}${code} — ${detail || "no detail"} (${url})`);
324
+ }
325
+ return parsed;
326
+ }
327
+ /** What the person sees: the artifact, the version, where to read it. */
328
+ function report(publication, io) {
329
+ const artifact = publication.artifact ?? {};
330
+ line(io.out, artifact.title ?? artifact.key ?? "(untitled artifact)");
331
+ if (publication.version !== null && publication.version !== undefined) {
332
+ line(io.out, `v${publication.version} ${publication.state ?? ""}`.trim());
333
+ }
334
+ else {
335
+ // The whole declared set was already recorded for this key (#590): a
336
+ // re-publication of an unchanged directory takes no version. Say it,
337
+ // rather than printing an address and letting the person assume a new
338
+ // version is live.
339
+ line(io.out, "no new version — every file was already published under this key");
340
+ }
341
+ if (publication.url)
342
+ line(io.out, publication.url);
343
+ if (publication.state === "draft") {
344
+ line(io.out, `awaits approval in the panel: ${publication.panelUrl || "(no panel link configured)"}`);
345
+ }
346
+ for (const skipped of publication.skipped ?? []) {
347
+ line(io.err, `unchanged: ${skipped}`);
348
+ }
349
+ for (const r of publication.rejected ?? []) {
350
+ line(io.err, `rejected: ${r.path} — ${r.reason}`);
351
+ }
352
+ }
353
+ function humanBytes(n) {
354
+ return n >= 1024 * 1024 ? `${(n / (1024 * 1024)).toFixed(1)} MiB` : `${n} bytes`;
355
+ }
356
+ //# sourceMappingURL=artifactsPublish.js.map
@@ -0,0 +1,168 @@
1
+ import { originOf } from "./credentials.js";
2
+ import { LoginFlowError, NotLoggedInError, decodeJwtPayload, deviceLogin, ensureAccessToken, spaceBaseUrl, } from "./entraLogin.js";
3
+ import { UsageError } from "./invoke.js";
4
+ import { insecureOriginReason } from "./secureOrigin.js";
5
+ /** One sentence for the person when a key shadows the sign-in they just
6
+ * made or asked about. */
7
+ export function apiKeyShadowNote(source) {
8
+ return (`Note: an API key is set (${source}) and a key always wins over the sign-in — ` +
9
+ `API commands will run AS THAT KEY on /public/api, not as you. ` +
10
+ `Unset it (or drop --api-key) to act as yourself.`);
11
+ }
12
+ /** The base URL an env var pins, when it names a DIFFERENT origin than the
13
+ * login just made: env wins over the stored default on every call, so the
14
+ * person would hit the other deployment unsigned — say so at login time. */
15
+ export function shadowingBaseUrl(env, origin) {
16
+ for (const name of ["DATUM_BASE_URL", "MCA_BASE_URL"]) {
17
+ const value = env[name]?.trim();
18
+ if (!value)
19
+ continue;
20
+ let other;
21
+ try {
22
+ other = originOf(value);
23
+ }
24
+ catch {
25
+ return { name, value };
26
+ }
27
+ if (other !== origin)
28
+ return { name, value: other };
29
+ return null; // the env agrees with the login — nothing to say
30
+ }
31
+ return null;
32
+ }
33
+ const line = (s, text) => s.write(`${text}\n`);
34
+ export async function runLogin(baseUrl, io) {
35
+ const origin = originOf(baseUrl);
36
+ try {
37
+ const login = await deviceLogin(origin, io.store, (device) => {
38
+ line(io.err, `Sign in to ${origin}:`);
39
+ line(io.err, ` open ${device.verificationUriComplete ?? device.verificationUri}`);
40
+ line(io.err, ` code ${device.userCode}`);
41
+ line(io.err, `Waiting for the sign-in to complete (the code is valid for ${Math.round((device.expiresAt - (io.now ?? Date.now)()) / 60000)} min)…`);
42
+ }, io);
43
+ line(io.err, `Signed in to ${origin} as ${login.account || "(unknown account)"}.`);
44
+ line(io.err, `Credentials: ${io.store.path} (mode 0600). This is now the default --base-url.`);
45
+ if (io.apiKeySource)
46
+ line(io.err, apiKeyShadowNote(io.apiKeySource));
47
+ const shadow = shadowingBaseUrl(io.env ?? process.env, origin);
48
+ if (shadow) {
49
+ line(io.err, `Note: ${shadow.name}=${shadow.value} is set in your shell and overrides that default — ` +
50
+ `commands without --base-url will target ${shadow.value}, where you are not signed in. ` +
51
+ `Unset it, or pass --base-url ${origin} (an MCP config can pin DATUM_BASE_URL in its env).`);
52
+ }
53
+ return 0;
54
+ }
55
+ catch (err) {
56
+ if (err instanceof UsageError) {
57
+ line(io.err, `datum: ${err.message}`);
58
+ return 2;
59
+ }
60
+ if (err instanceof LoginFlowError) {
61
+ line(io.err, `datum: ${err.message}`);
62
+ return 1;
63
+ }
64
+ throw err;
65
+ }
66
+ }
67
+ export async function runLogout(baseUrl, io) {
68
+ const origin = originOf(baseUrl);
69
+ const had = io.store.delete(origin);
70
+ line(io.err, had ? `Signed out of ${origin} (local credentials removed; the Microsoft session itself is untouched).` : `Not signed in to ${origin} — nothing to remove.`);
71
+ return 0;
72
+ }
73
+ /** Who the SERVER says we are (GET /api/me with the bearer) — the honest
74
+ * answer; the token's own claims are only the fallback when the server is
75
+ * unreachable. */
76
+ /** `whoami` answered by the API key (no sign-in on this machine): what
77
+ * `/public/api/me` says of the key, or the same "invalid API key" every
78
+ * other command reports for a key the server refuses. */
79
+ async function whoamiByKey(target, io) {
80
+ const insecure = insecureOriginReason(target);
81
+ if (insecure)
82
+ throw new UsageError(insecure);
83
+ const doFetch = io.fetchImpl ?? fetch;
84
+ const res = await doFetch(`${target}/public/api/me`, {
85
+ headers: { authorization: `Bearer ${io.apiKey}`, accept: "application/json" },
86
+ redirect: "manual",
87
+ });
88
+ const body = (await res.json().catch(() => ({})));
89
+ if (!res.ok) {
90
+ const detail = typeof body.error === "string" ? body.error : `HTTP ${res.status}`;
91
+ line(io.err, `datum: ${target} answered HTTP ${res.status} — ${detail} (the key from ${io.apiKeySource ?? "the environment"}). ` +
92
+ `Fix or unset it, or sign in as yourself: \`datum login --base-url ${originOf(target)}\`.`);
93
+ return 1;
94
+ }
95
+ line(io.out, JSON.stringify({
96
+ baseUrl: target,
97
+ identity: body.identity ?? null,
98
+ role: body.role ?? null,
99
+ roleSource: body.roleSource ?? null,
100
+ authVia: body.authVia ?? "api-key",
101
+ // A personal agent's person token (#2719): the person, through their agent.
102
+ ...(body.via ? { via: body.via } : {}),
103
+ // …and whether it acts with the person's full reach (#3028).
104
+ ...(typeof body.fullReach === "boolean" ? { fullReach: body.fullReach } : {}),
105
+ nextCallUses: `api-key (${io.apiKeySource ?? "?"}) on /public/api`,
106
+ signedIn: false,
107
+ }, null, 2));
108
+ return 0;
109
+ }
110
+ export async function runWhoami(baseUrl, space, io) {
111
+ const origin = originOf(baseUrl);
112
+ const doFetch = io.fetchImpl ?? fetch;
113
+ const target = space ? spaceBaseUrl(origin, space) : origin;
114
+ let token;
115
+ try {
116
+ token = await ensureAccessToken(origin, io.store, io);
117
+ }
118
+ catch (err) {
119
+ // No sign-in, but a key in the flags/environment: the key is what the
120
+ // next command uses — check IT, like every API command does, instead of
121
+ // "not signed in (or set DATUM_API_KEY)" to a person whose key is set.
122
+ if (err instanceof NotLoggedInError && io.apiKey)
123
+ return whoamiByKey(target, io);
124
+ if (err instanceof NotLoggedInError || err instanceof LoginFlowError) {
125
+ line(io.err, `datum: ${err.message}`);
126
+ return 1;
127
+ }
128
+ throw err;
129
+ }
130
+ const res = await doFetch(`${target}/api/me`, {
131
+ headers: { authorization: `Bearer ${token}`, accept: "application/json" },
132
+ redirect: "manual",
133
+ });
134
+ const body = (await res.json().catch(() => ({})));
135
+ if (!res.ok) {
136
+ const payload = decodeJwtPayload(token);
137
+ line(io.err, `datum: ${target} answered HTTP ${res.status}${typeof body.error === "string" ? ` — ${body.error}` : ""}`);
138
+ if (body.code === "feature_disabled") {
139
+ line(io.err, "datum: developer access is off for this space — a space admin enables it in Settings → Features");
140
+ }
141
+ line(io.err, `datum: the token says you are ${String(payload.preferred_username ?? payload.upn ?? "?")}`);
142
+ return 1;
143
+ }
144
+ // The honest answer to "who am I" includes which credential the NEXT API
145
+ // command uses: with a key in the flags/environment it is the key (its own
146
+ // actor, the public surface), whatever this sign-in says.
147
+ const nextCallUses = io.apiKeySource
148
+ ? `api-key (${io.apiKeySource}) on /public/api — NOT this sign-in`
149
+ : "this sign-in (bearer) on /api";
150
+ if (io.apiKeySource)
151
+ line(io.err, apiKeyShadowNote(io.apiKeySource));
152
+ line(io.out, JSON.stringify({
153
+ baseUrl: target,
154
+ identity: body.identity ?? null,
155
+ role: body.role ?? null,
156
+ roleSource: body.roleSource ?? null,
157
+ orgRole: body.orgRole ?? null,
158
+ // Human-readable: the space slugs (older servers answer ids only).
159
+ spaces: Array.isArray(body.spaces)
160
+ ? body.spaces.map((s) => s.name && s.name !== s.slug ? `${s.slug} (${s.name})` : (s.slug ?? "?"))
161
+ : (body.spaceIds ?? []),
162
+ authVia: body.authVia ?? null,
163
+ nextCallUses,
164
+ credentials: io.store.path,
165
+ }, null, 2));
166
+ return 0;
167
+ }
168
+ //# sourceMappingURL=authCommands.js.map
@@ -0,0 +1,111 @@
1
+ import { createPublicKey, verify } from "node:crypto";
2
+ /**
3
+ * The signature of the CLI bundle a self-update installs (#2612).
4
+ *
5
+ * The integrity the CLI checks comes from the SAME server as the bundle, so
6
+ * on its own it proves nothing against a hostile server: `datum login
7
+ * --base-url https://<their-host>` and that host serves whatever it likes.
8
+ * The signature ties the bundle to Datum's CI instead of to the host:
9
+ *
10
+ * ROOT key (AWS KMS, alias/datum-cli-bundle-signing; public half pinned
11
+ * below) ──signs──▶ BUILD key statement { version, build key }
12
+ * BUILD key (one-shot, generated per image build) ──signs──▶ bundle
13
+ * statement { package name, version, integrity }
14
+ *
15
+ * Two steps because the bundle is built INSIDE `docker build`, which never
16
+ * sees AWS credentials: the image builder's custom-script (on the runner,
17
+ * with the push role's credentials) mints a build key and has KMS sign it
18
+ * (scripts/ci/cli-bundle-build-key.sh); the build stage signs the bundle
19
+ * with it (scripts/sign-cli-bundle.mjs). The build key's statement names
20
+ * the version, so a leaked build key can sign nothing but bundles of that
21
+ * one version.
22
+ *
23
+ * Statements are fixed text, never JSON — the shell script, the build
24
+ * stage and this module must produce identical bytes (signatureFormat.test).
25
+ */
26
+ /** ECDSA P-256 over SHA-256, DER-encoded — what KMS `ECDSA_SHA_256` returns. */
27
+ export const BUNDLE_SIGNATURE_ALGORITHM = "ECDSA_SHA_256";
28
+ /**
29
+ * Root public keys the CLI trusts: KMS key id → SubjectPublicKeyInfo DER,
30
+ * base64 (`aws kms get-public-key --query PublicKey`). Rotation ADDS a key
31
+ * and removes the old one a release later — a CLI only trusts what it ships.
32
+ */
33
+ export const CLI_BUNDLE_ROOT_KEYS = {
34
+ // The release root key (AWS KMS alias/datum-cli-bundle-signing, ECC_NIST_P256).
35
+ "6863765b-af9c-451c-8dc9-babc5807dbc8": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEY+IdP1ekSApHIhR1fXjhliORWmHJgCrnzRUQC0i+8tFmaUbCkh77JyulTvGTZJeTClBueu6raqzXzllSR+oysg==",
36
+ };
37
+ /**
38
+ * Whether a CLI refuses an unsigned bundle. On since the CLI went to the
39
+ * public npm registry (#2613): anybody can now be talked into `datum login
40
+ * --base-url https://<their-host>`, and only the signature keeps that host
41
+ * from handing over code. A bundle signed by a root key this CLI does not
42
+ * pin counts as unsigned. The one exception is a loopback dev server.
43
+ */
44
+ export const REQUIRE_SIGNED_BUNDLES = true;
45
+ /** `1` makes this machine refuse unsigned bundles before the default does. */
46
+ export const ENV_REQUIRE_SIGNED_UPDATE = "DATUM_REQUIRE_SIGNED_UPDATE";
47
+ export function buildKeyStatement(version, key) {
48
+ return `datum-cli-build-key/v1\nversion=${version}\nkey=${key}\n`;
49
+ }
50
+ export function bundleStatement(name, version, integrity) {
51
+ return `datum-cli-bundle/v1\nname=${name}\nversion=${version}\nintegrity=${integrity}\n`;
52
+ }
53
+ export function isBundleSignature(v) {
54
+ if (!v || typeof v !== "object")
55
+ return false;
56
+ const o = v;
57
+ const k = o.buildKey;
58
+ return (typeof o.name === "string" &&
59
+ typeof o.signature === "string" &&
60
+ !!k &&
61
+ typeof k === "object" &&
62
+ typeof k.key === "string" &&
63
+ typeof k.rootKeyId === "string" &&
64
+ typeof k.signature === "string");
65
+ }
66
+ export class BundleSignatureError extends Error {
67
+ }
68
+ function verifies(statement, spkiBase64, signatureBase64) {
69
+ try {
70
+ const key = createPublicKey({ key: Buffer.from(spkiBase64, "base64"), format: "der", type: "spki" });
71
+ return verify("sha256", Buffer.from(statement, "utf8"), key, Buffer.from(signatureBase64, "base64"));
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ }
77
+ /**
78
+ * Judge the signature a server announced for a bundle. `{ signed: false }`
79
+ * when there is nothing this CLI can check (no signature, a root key it does
80
+ * not know — a rotation it predates); THROWS when a signature is there and
81
+ * does not hold, or holds for another package or version — that is never a
82
+ * bundle to install, whatever the policy.
83
+ */
84
+ export function checkBundleSignature(pkg, opts) {
85
+ const rootKeys = opts.rootKeys ?? CLI_BUNDLE_ROOT_KEYS;
86
+ if (pkg.signature === undefined || pkg.signature === null)
87
+ return { signed: false, reason: "the server announced no signature" };
88
+ if (!isBundleSignature(pkg.signature))
89
+ throw new BundleSignatureError("the server announced a malformed bundle signature");
90
+ const sig = pkg.signature;
91
+ const root = rootKeys[sig.buildKey.rootKeyId];
92
+ if (!root)
93
+ return { signed: false, reason: `signed by a root key this CLI does not know (${sig.buildKey.rootKeyId})` };
94
+ if (!verifies(buildKeyStatement(pkg.version, sig.buildKey.key), root, sig.buildKey.signature)) {
95
+ throw new BundleSignatureError(`the bundle's build key is not signed for ${pkg.version} by the Datum root key`);
96
+ }
97
+ if (!opts.packageNames.includes(sig.name)) {
98
+ throw new BundleSignatureError(`the bundle is signed as ${sig.name}, not as the datum CLI`);
99
+ }
100
+ if (!verifies(bundleStatement(sig.name, pkg.version, pkg.integrity), sig.buildKey.key, sig.signature)) {
101
+ throw new BundleSignatureError("the bundle signature does not match its version and integrity");
102
+ }
103
+ return { signed: true, rootKeyId: sig.buildKey.rootKeyId, name: sig.name };
104
+ }
105
+ /** Is an unsigned bundle refused here? Never for a loopback dev server. */
106
+ export function signatureRequired(env, loopback, base = REQUIRE_SIGNED_BUNDLES) {
107
+ if (loopback)
108
+ return false;
109
+ return base || env[ENV_REQUIRE_SIGNED_UPDATE]?.trim() === "1";
110
+ }
111
+ //# sourceMappingURL=bundleSignature.js.map