@datarails-ai/dr-appspace-cli 0.12.0 → 0.12.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.
Files changed (2) hide show
  1. package/dist/cli.js +335 -30
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -13,7 +13,7 @@ import { fileURLToPath as fileURLToPath6 } from "url";
13
13
  // package.json
14
14
  var package_default = {
15
15
  name: "@datarails-ai/dr-appspace-cli",
16
- version: "0.12.0",
16
+ version: "0.12.2",
17
17
  description: "Datarails App Space CLI \u2014 build, try, push and publish App Space apps",
18
18
  type: "module",
19
19
  engines: {
@@ -57,7 +57,7 @@ var package_default = {
57
57
  };
58
58
 
59
59
  // src/buildInfo.ts
60
- var BUILD_TIME = "2026-10-07T17:05:49.665Z";
60
+ var BUILD_TIME = "2026-10-08T08:05:29.934Z";
61
61
  function formatBuildTime(iso) {
62
62
  if (iso === "") return "";
63
63
  const d = new Date(iso);
@@ -208,7 +208,17 @@ function decodeShape(value) {
208
208
 
209
209
  // src/auth/keyring/types.ts
210
210
  function isCookielessSession(s) {
211
- return s.impersonatorEmail !== void 0 && s.impersonatorEmail !== "" || s.cookieless === true;
211
+ return s.impersonatorEmail !== void 0 && s.impersonatorEmail !== "" || s.cookieless === true || hasApiKey(s) || hasApiKeyEnvVar(s);
212
+ }
213
+ function hasApiKey(s) {
214
+ return s.apiKey !== void 0 && s.apiKey !== "";
215
+ }
216
+ function hasApiKeyEnvVar(s) {
217
+ return s.apiKeyEnvVar !== void 0 && s.apiKeyEnvVar !== "";
218
+ }
219
+ function usesApiKey(s) {
220
+ const impersonated = s.impersonatorEmail !== void 0 && s.impersonatorEmail !== "";
221
+ return (hasApiKey(s) || hasApiKeyEnvVar(s)) && !impersonated;
212
222
  }
213
223
  var KeychainError = class extends Error {
214
224
  constructor(message, options) {
@@ -428,7 +438,9 @@ var KeyringStore = class _KeyringStore {
428
438
  try {
429
439
  parsed = JSON.parse(raw);
430
440
  } catch (err) {
431
- throw new KeychainError(`decode session for ${serverKey}:${email}`, { cause: err });
441
+ throw new KeychainError(`decode session for ${serverKey}:${email}`, {
442
+ cause: err
443
+ });
432
444
  }
433
445
  const sessionId = typeof parsed.sessionId === "string" ? parsed.sessionId : "";
434
446
  const csrfToken = typeof parsed.csrfToken === "string" ? parsed.csrfToken : "";
@@ -436,7 +448,10 @@ var KeyringStore = class _KeyringStore {
436
448
  const impersonatorEmail = typeof parsed.impersonatorEmail === "string" ? parsed.impersonatorEmail : "";
437
449
  const impersonated = impersonatorEmail !== "";
438
450
  const cookielessFlag = parsed.cookieless === true;
439
- const cookieless = impersonated || cookielessFlag;
451
+ const apiKey = typeof parsed.apiKey === "string" && parsed.apiKey !== "" ? parsed.apiKey : "";
452
+ const apiKeyEnvVar = typeof parsed.apiKeyEnvVar === "string" && parsed.apiKeyEnvVar !== "" ? parsed.apiKeyEnvVar : "";
453
+ const keyed = apiKey !== "" || apiKeyEnvVar !== "";
454
+ const cookieless = impersonated || cookielessFlag || keyed;
440
455
  if (savedAtStr === "" || !cookieless && (sessionId === "" || csrfToken === "")) {
441
456
  throw new KeychainError(`session for ${serverKey}:${email} is malformed`);
442
457
  }
@@ -458,6 +473,18 @@ var KeyringStore = class _KeyringStore {
458
473
  if (cookielessFlag) {
459
474
  session.cookieless = true;
460
475
  }
476
+ if (apiKey !== "") {
477
+ session.apiKey = apiKey;
478
+ }
479
+ if (apiKeyEnvVar !== "") {
480
+ session.apiKeyEnvVar = apiKeyEnvVar;
481
+ }
482
+ if (typeof parsed.apiKeyExpiresAt === "string" && parsed.apiKeyExpiresAt !== "") {
483
+ session.apiKeyExpiresAt = new Date(parsed.apiKeyExpiresAt);
484
+ }
485
+ if (typeof parsed.jwtApiKeyFingerprint === "string" && parsed.jwtApiKeyFingerprint !== "") {
486
+ session.jwtApiKeyFingerprint = parsed.jwtApiKeyFingerprint;
487
+ }
461
488
  if (typeof parsed.jwtExpiresAt === "string" && parsed.jwtExpiresAt !== "") {
462
489
  session.jwtExpiresAt = new Date(parsed.jwtExpiresAt);
463
490
  }
@@ -499,7 +526,7 @@ var KeyringStore = class _KeyringStore {
499
526
  const impersonated = session.impersonatorEmail !== void 0 && session.impersonatorEmail !== "";
500
527
  const cookieless = session.cookieless === true;
501
528
  if (isCookielessSession(session)) {
502
- if (session.jwtAccess === void 0 || session.jwtAccess === "") {
529
+ if (!usesApiKey(session) && (session.jwtAccess === void 0 || session.jwtAccess === "")) {
503
530
  throw new Error("saveSession: cookie-less session must have a jwtAccess token");
504
531
  }
505
532
  } else {
@@ -533,6 +560,18 @@ var KeyringStore = class _KeyringStore {
533
560
  if (cookieless) {
534
561
  metaBlob.cookieless = true;
535
562
  }
563
+ if (session.apiKey !== void 0 && session.apiKey !== "") {
564
+ metaBlob.apiKey = session.apiKey;
565
+ }
566
+ if (session.apiKeyEnvVar !== void 0 && session.apiKeyEnvVar !== "") {
567
+ metaBlob.apiKeyEnvVar = session.apiKeyEnvVar;
568
+ }
569
+ if (session.apiKeyExpiresAt !== void 0) {
570
+ metaBlob.apiKeyExpiresAt = session.apiKeyExpiresAt.toISOString();
571
+ }
572
+ if (session.jwtApiKeyFingerprint !== void 0 && session.jwtApiKeyFingerprint !== "") {
573
+ metaBlob.jwtApiKeyFingerprint = session.jwtApiKeyFingerprint;
574
+ }
536
575
  await this.backend.set(SERVICE, sessionAccount, JSON.stringify(metaBlob));
537
576
  if (session.jwtAccess !== void 0 && session.jwtAccess !== "") {
538
577
  await this.backend.set(SERVICE, `${sessionAccount}:access`, session.jwtAccess);
@@ -564,7 +603,11 @@ var KeyringStore = class _KeyringStore {
564
603
  blob.lastUsedAt = now.toISOString();
565
604
  await this.backend.set(SERVICE, sessionAccount, JSON.stringify(blob));
566
605
  const savedAt = parseDate(blob.savedAt) ?? now;
567
- await this.tryUpsertLegacyIndex(serverKey, { email, savedAt, lastUsedAt: now });
606
+ await this.tryUpsertLegacyIndex(serverKey, {
607
+ email,
608
+ savedAt,
609
+ lastUsedAt: now
610
+ });
568
611
  }
569
612
  /**
570
613
  * Upserts one account into the legacy `__index__:<server>` entry via a
@@ -826,6 +869,9 @@ async function pollForToken(host, deviceCode, options = {}) {
826
869
  }
827
870
  }
828
871
 
872
+ // src/auth/jwt.ts
873
+ import { createHash } from "crypto";
874
+
829
875
  // src/auth/types.ts
830
876
  function jwtExpiringSoon(e, bufferMs) {
831
877
  if (!e.jwtExpiresAt) return true;
@@ -1040,7 +1086,11 @@ async function loginAs(host, operatorAccessToken, userId, options) {
1040
1086
  if (typeof parsed["detail"] === "string") detail = parsed["detail"];
1041
1087
  } catch {
1042
1088
  }
1043
- throw new LoginAsError(res.status, { code, detail, body: truncate(text3, TRUNCATE) });
1089
+ throw new LoginAsError(res.status, {
1090
+ code,
1091
+ detail,
1092
+ body: truncate(text3, TRUNCATE)
1093
+ });
1044
1094
  }
1045
1095
  let raw;
1046
1096
  try {
@@ -1065,6 +1115,93 @@ async function loginAs(host, operatorAccessToken, userId, options) {
1065
1115
  }
1066
1116
  return { access, refresh, expiresAt, user: obj4["user"] };
1067
1117
  }
1118
+ async function exchangeApiKey(host, apiKey, options) {
1119
+ const key = apiKey.trim();
1120
+ if (key === "") {
1121
+ throw new Error("api-key exchange: the stored API key is empty");
1122
+ }
1123
+ if (options?.opaque === true) {
1124
+ if (/[\r\n\0]/.test(key) || !/^[\x00-\xff]+$/.test(key)) {
1125
+ throw new Error(
1126
+ "api-key exchange: the environment variable named by apiKeyEnvVar holds a value that cannot travel in an HTTP header (a line break, NUL, or a character above U+00FF)"
1127
+ );
1128
+ }
1129
+ } else if (!/^[\x21-\x7e]+$/.test(key)) {
1130
+ throw new Error(
1131
+ "api-key exchange: only a printable-ASCII API key with no spaces is accepted; the stored one contains whitespace, control characters, or non-ASCII characters"
1132
+ );
1133
+ }
1134
+ const base = trimTrailingSlash(host);
1135
+ const res = await fetch(`${base}/jwt/api/apikey/token/`, {
1136
+ method: "POST",
1137
+ headers: { Authorization: `Bearer ${key}`, Accept: "application/json" },
1138
+ signal: options?.signal
1139
+ });
1140
+ const text3 = await res.text();
1141
+ if (!res.ok) {
1142
+ let code;
1143
+ try {
1144
+ const value = JSON.parse(text3)["error"];
1145
+ if (value === "invalid_key" || value === "exchange_unavailable") code = value;
1146
+ } catch {
1147
+ }
1148
+ throw new Error(
1149
+ `api-key exchange: HTTP ${res.status}${code !== void 0 ? ` (${code})` : ""}`
1150
+ );
1151
+ }
1152
+ let raw;
1153
+ try {
1154
+ raw = JSON.parse(text3);
1155
+ } catch {
1156
+ throw new Error("api-key exchange parse: the response is not JSON");
1157
+ }
1158
+ if (raw === null || typeof raw !== "object") {
1159
+ throw new Error("api-key exchange parse: expected object");
1160
+ }
1161
+ const access = raw["access_token"];
1162
+ if (typeof access !== "string" || access === "") {
1163
+ throw new Error("api-key exchange: empty access token");
1164
+ }
1165
+ let expiresAt;
1166
+ try {
1167
+ expiresAt = decodeJwtExpiration(access);
1168
+ } catch (err) {
1169
+ throw new Error(`api-key exchange: ${msgOf3(err)}`);
1170
+ }
1171
+ return { access, expiresAt };
1172
+ }
1173
+ function apiKeyFingerprint(apiKey) {
1174
+ return createHash("sha256").update(apiKey.trim()).digest("hex").slice(0, 32);
1175
+ }
1176
+ function resolveApiKeyMaterial(session) {
1177
+ if (session.apiKey !== void 0 && session.apiKey !== "") {
1178
+ return { key: session.apiKey, fromEnv: false };
1179
+ }
1180
+ const name = session.apiKeyEnvVar;
1181
+ if (name === void 0 || name === "") {
1182
+ throw new Error("api-key exchange: the session carries no API key");
1183
+ }
1184
+ const value = process.env[name];
1185
+ if (value === void 0 || value === "") {
1186
+ throw new Error(
1187
+ `api-key exchange: the credential file names environment variable ${name} as the API-key carrier, but it is unset or empty in this environment`
1188
+ );
1189
+ }
1190
+ return { key: value, fromEnv: true };
1191
+ }
1192
+ function mintedByStoredApiKey(session) {
1193
+ if (session.jwtAccess === void 0 || session.jwtAccess === "") return false;
1194
+ if (session.jwtApiKeyFingerprint === void 0 || session.jwtApiKeyFingerprint === "") {
1195
+ return false;
1196
+ }
1197
+ let material;
1198
+ try {
1199
+ material = resolveApiKeyMaterial(session);
1200
+ } catch {
1201
+ return false;
1202
+ }
1203
+ return session.jwtApiKeyFingerprint === apiKeyFingerprint(material.key);
1204
+ }
1068
1205
  async function ensureValidJwt(store, serverKey, email, options) {
1069
1206
  return ensureJwt(store, serverKey, email, {
1070
1207
  buffer: options?.buffer ?? 0,
@@ -1086,6 +1223,10 @@ async function ensureJwt(store, serverKey, email, opts) {
1086
1223
  if (session === void 0) {
1087
1224
  throw new NotLoggedInError(`not logged in: ${serverKey}:${email}`);
1088
1225
  }
1226
+ if (usesApiKey(session)) {
1227
+ const host2 = opts.host ?? resolve(serverKey).host;
1228
+ return exchangeKeyedSession(store, serverKey, email, session, host2, opts);
1229
+ }
1089
1230
  if (!opts.force && session.jwtAccess !== void 0 && session.jwtAccess !== "" && !jwtExpiringSoon(session, opts.buffer)) {
1090
1231
  return session;
1091
1232
  }
@@ -1099,7 +1240,9 @@ async function ensureJwt(store, serverKey, email, opts) {
1099
1240
  if (session.jwtRefresh !== void 0 && session.jwtRefresh !== "" && host !== "") {
1100
1241
  try {
1101
1242
  const refresh = session.cookieless === true ? refreshExternalJwt : refreshJwt;
1102
- const tok = await refresh(host, session.jwtRefresh, { signal: opts.signal });
1243
+ const tok = await refresh(host, session.jwtRefresh, {
1244
+ signal: opts.signal
1245
+ });
1103
1246
  refreshNext = {
1104
1247
  ...session,
1105
1248
  jwtAccess: tok.access,
@@ -1157,10 +1300,47 @@ async function ensureJwt(store, serverKey, email, opts) {
1157
1300
  const detail = reasons.length > 0 ? reasons.join("; ") : "no recovery path available";
1158
1301
  throw new SessionExpiredError(`${serverKey}:${email} (${detail})`);
1159
1302
  }
1303
+ async function exchangeKeyedSession(store, serverKey, email, session, host, opts) {
1304
+ if (!opts.force && mintedByStoredApiKey(session) && !jwtExpiringSoon(session, opts.buffer)) {
1305
+ return session;
1306
+ }
1307
+ if (host === "") {
1308
+ throw new SessionExpiredError(
1309
+ `${serverKey}:${email} (no host to exchange the API key against)`
1310
+ );
1311
+ }
1312
+ const material = resolveApiKeyMaterial(session);
1313
+ let tok;
1314
+ try {
1315
+ tok = await exchangeApiKey(host, material.key, {
1316
+ signal: opts.signal,
1317
+ opaque: material.fromEnv
1318
+ });
1319
+ } catch (err) {
1320
+ throw new SessionExpiredError(`${serverKey}:${email} (${msgOf3(err)})`);
1321
+ }
1322
+ const next = {
1323
+ ...session,
1324
+ jwtAccess: tok.access,
1325
+ jwtExpiresAt: tok.expiresAt,
1326
+ jwtApiKeyFingerprint: apiKeyFingerprint(material.key),
1327
+ // An API-key identity has no refresh token; clear any stale one so the
1328
+ // refresh ladder is never tempted by it on a later non-keyed read.
1329
+ jwtRefresh: void 0
1330
+ };
1331
+ try {
1332
+ await store.saveSession(serverKey, next);
1333
+ } catch (err) {
1334
+ throw new Error(`save credentials: ${msgOf3(err)}`, { cause: err });
1335
+ }
1336
+ return next;
1337
+ }
1160
1338
  async function recoverImpersonatedJwt(store, serverKey, email, session, host, opts) {
1161
1339
  if (session.jwtRefresh !== void 0 && session.jwtRefresh !== "" && host !== "") {
1162
1340
  try {
1163
- const tok = await refreshExternalJwt(host, session.jwtRefresh, { signal: opts.signal });
1341
+ const tok = await refreshExternalJwt(host, session.jwtRefresh, {
1342
+ signal: opts.signal
1343
+ });
1164
1344
  const next2 = {
1165
1345
  ...session,
1166
1346
  jwtAccess: tok.access,
@@ -1341,7 +1521,7 @@ var AuthorizedClient = class _AuthorizedClient {
1341
1521
  }
1342
1522
  const cookieless = session.cookieless === true;
1343
1523
  if (isCookielessSession(session)) {
1344
- if (session.jwtAccess === void 0 || session.jwtAccess === "") {
1524
+ if (!usesApiKey(session) && (session.jwtAccess === void 0 || session.jwtAccess === "")) {
1345
1525
  throw new NotLoggedInError(
1346
1526
  cookieless ? `not logged in: ${serverKey}:${email} (cookie-less session has no JWT)` : `not logged in: ${serverKey}:${email} (impersonated session has no JWT; re-run dr-appspace login-as)`
1347
1527
  );
@@ -1845,7 +2025,7 @@ var AuthorizedClient = class _AuthorizedClient {
1845
2025
  async ensureFreshAuth(bufferMs, signal) {
1846
2026
  const access = this._session.jwtAccess ?? "";
1847
2027
  const refresh = this._session.jwtRefresh ?? "";
1848
- if (access === "" && refresh === "") {
2028
+ if (access === "" && refresh === "" && !usesApiKey(this._session)) {
1849
2029
  return;
1850
2030
  }
1851
2031
  try {
@@ -1894,8 +2074,27 @@ import { z } from "zod";
1894
2074
  var CREDENTIALS_FILE_ENV = "DR_CREDENTIALS_FILE";
1895
2075
  var DEFAULT_CREDENTIALS_FILE = join3(homedir3(), ".dr", "session.json");
1896
2076
  var credentialsFileSchema = z.object({
1897
- access: z.string().min(1),
1898
- refresh: z.string().min(1),
2077
+ access: z.string().min(1).optional(),
2078
+ refresh: z.string().min(1).optional(),
2079
+ // A user-bound API key, exchanged for `access` on demand. A file may hold
2080
+ // only this; `access` then appears after the first exchange.
2081
+ apiKey: z.string().min(1).optional(),
2082
+ // DR-52657: the NAME of an environment variable holding the API key, for
2083
+ // provisioners that must not put the key on disk — an agent sandbox, where
2084
+ // the variable's in-sandbox value is an opaque vault placeholder, or a CI
2085
+ // runner keeping the key in its secret env. Resolved at exchange time and
2086
+ // passed verbatim. Mutually exclusive with `apiKey` (refinement below).
2087
+ apiKeyEnvVar: z.string().min(1).optional(),
2088
+ // Carried verbatim, judged where it is read. `z.string()` (not validated or
2089
+ // `.catch`): a rotation rewrites the whole file from the parsed object, so a
2090
+ // value this schema dropped would be deleted silently, and a top-level
2091
+ // rejection would fail the WHOLE file and lock the operator out of logout.
2092
+ apiKeyExpiresAt: z.string().optional(),
2093
+ // Which key minted `access`, as a non-secret fingerprint. Written by the
2094
+ // CLI, not a provisioner: a rotation replaces `apiKey` and leaves this
2095
+ // naming the old one, which is how the next command knows `access` is not
2096
+ // the new key's. `z.string()` for the same lock-out reason as above.
2097
+ accessApiKeyFingerprint: z.string().optional(),
1899
2098
  server: z.string().min(1),
1900
2099
  email: z.string().min(1),
1901
2100
  // min(1): an empty string is how a hand-rolled producer spells "no cookie",
@@ -1911,7 +2110,19 @@ var credentialsFileSchema = z.object({
1911
2110
  }).loose().refine((c) => c.sessionId === void 0 === (c.csrfToken === void 0), {
1912
2111
  message: "sessionId and csrfToken must be provided together",
1913
2112
  path: ["sessionId"]
1914
- });
2113
+ }).refine((c) => !(c.apiKey !== void 0 && c.apiKeyEnvVar !== void 0), {
2114
+ message: "apiKey and apiKeyEnvVar are mutually exclusive",
2115
+ path: ["apiKeyEnvVar"]
2116
+ }).refine((c) => c.access !== void 0 || c.apiKey !== void 0 || c.apiKeyEnvVar !== void 0, {
2117
+ message: "one of access, apiKey or apiKeyEnvVar is required",
2118
+ path: ["access"]
2119
+ }).refine(
2120
+ (c) => c.apiKey !== void 0 || c.apiKeyEnvVar !== void 0 || c.refresh !== void 0 || c.sessionId !== void 0,
2121
+ {
2122
+ message: "refresh is required unless an apiKey, apiKeyEnvVar or a session cookie is present",
2123
+ path: ["refresh"]
2124
+ }
2125
+ );
1915
2126
  function credentialsFilePath() {
1916
2127
  const override = process.env[CREDENTIALS_FILE_ENV];
1917
2128
  return override !== void 0 && override !== "" ? override : DEFAULT_CREDENTIALS_FILE;
@@ -1924,8 +2135,14 @@ async function readCredentialsFile(path3) {
1924
2135
  if (err.code === "ENOENT") return void 0;
1925
2136
  throw err;
1926
2137
  }
2138
+ let parsed;
2139
+ try {
2140
+ parsed = JSON.parse(raw);
2141
+ } catch {
2142
+ throw new Error(`credential file ${path3} is not valid JSON`);
2143
+ }
1927
2144
  try {
1928
- return credentialsFileSchema.parse(JSON.parse(raw));
2145
+ return credentialsFileSchema.parse(parsed);
1929
2146
  } catch (err) {
1930
2147
  const reason = err instanceof Error ? err.message : String(err);
1931
2148
  throw new Error(`credential file ${path3} is malformed: ${reason}`, { cause: err });
@@ -1933,11 +2150,19 @@ async function readCredentialsFile(path3) {
1933
2150
  }
1934
2151
  async function writeCredentialsFile(path3, creds) {
1935
2152
  await mkdir(dirname2(path3), { recursive: true, mode: 448 });
1936
- const body = JSON.stringify(
1937
- { ...creds, savedAt: creds.savedAt ?? (/* @__PURE__ */ new Date()).toISOString() },
1938
- null,
1939
- 2
1940
- );
2153
+ let next;
2154
+ try {
2155
+ next = credentialsFileSchema.parse({
2156
+ ...creds,
2157
+ savedAt: creds.savedAt ?? (/* @__PURE__ */ new Date()).toISOString()
2158
+ });
2159
+ } catch (err) {
2160
+ const reason = err instanceof Error ? err.message : String(err);
2161
+ throw new Error(`refusing to write an unreadable credential file ${path3}: ${reason}`, {
2162
+ cause: err
2163
+ });
2164
+ }
2165
+ const body = JSON.stringify(next, null, 2);
1941
2166
  const tmp = `${path3}.${process.pid}.tmp`;
1942
2167
  await writeFile(tmp, body, { mode: 384 });
1943
2168
  await chmod(tmp, 384);
@@ -1966,9 +2191,11 @@ var FileKeyringBackend = class _FileKeyringBackend {
1966
2191
  const savedAt = creds.savedAt ?? (/* @__PURE__ */ new Date()).toISOString();
1967
2192
  const sessionId = creds.sessionId ?? "";
1968
2193
  let jwtExpiresAt;
1969
- try {
1970
- jwtExpiresAt = decodeJwtExpiration(creds.access).toISOString();
1971
- } catch {
2194
+ if (creds.access !== void 0 && creds.access !== "") {
2195
+ try {
2196
+ jwtExpiresAt = decodeJwtExpiration(creds.access).toISOString();
2197
+ } catch {
2198
+ }
1972
2199
  }
1973
2200
  return JSON.stringify({
1974
2201
  sessionId,
@@ -1976,7 +2203,19 @@ var FileKeyringBackend = class _FileKeyringBackend {
1976
2203
  savedAt,
1977
2204
  lastUsedAt: savedAt,
1978
2205
  ...sessionId === "" && { cookieless: true },
1979
- ...jwtExpiresAt !== void 0 && { jwtExpiresAt }
2206
+ ...jwtExpiresAt !== void 0 && { jwtExpiresAt },
2207
+ // Carry the API-key carrier through so `KeyringStore.getSession` sees a
2208
+ // keyed identity and routes it to the exchange, plus the fingerprint of the
2209
+ // key that minted the stored `access` so a reload can tell the cached JWT
2210
+ // is still the current key's. Without it a file store would re-exchange on
2211
+ // EVERY request — `ensureFreshAuth` reloads the session each time, and
2212
+ // `mintedByStoredApiKey` can never match a fingerprint that was never
2213
+ // written. `set('meta')` persists it back below.
2214
+ ...creds.apiKey !== void 0 && creds.apiKey !== "" && { apiKey: creds.apiKey },
2215
+ ...creds.apiKeyEnvVar !== void 0 && creds.apiKeyEnvVar !== "" && { apiKeyEnvVar: creds.apiKeyEnvVar },
2216
+ ...creds.accessApiKeyFingerprint !== void 0 && creds.accessApiKeyFingerprint !== "" && {
2217
+ jwtApiKeyFingerprint: creds.accessApiKeyFingerprint
2218
+ }
1980
2219
  });
1981
2220
  }
1982
2221
  static slotOf(creds, account) {
@@ -2001,6 +2240,20 @@ var FileKeyringBackend = class _FileKeyringBackend {
2001
2240
  ...csrfToken !== "" && { csrfToken }
2002
2241
  };
2003
2242
  }
2243
+ /**
2244
+ * The key fingerprint out of a metadata blob (`KeyringStore` emits it as
2245
+ * `jwtApiKeyFingerprint`), empty when absent. Unlike the cookie it is NOT
2246
+ * derived, so it has to be persisted back to the file or every request
2247
+ * re-exchanges.
2248
+ */
2249
+ static fingerprintOf(blob) {
2250
+ try {
2251
+ const parsed = JSON.parse(blob);
2252
+ return typeof parsed.jwtApiKeyFingerprint === "string" ? parsed.jwtApiKeyFingerprint : "";
2253
+ } catch {
2254
+ return "";
2255
+ }
2256
+ }
2004
2257
  async get(_service, account) {
2005
2258
  const creds = await this.load();
2006
2259
  if (creds === void 0) return void 0;
@@ -2026,10 +2279,14 @@ var FileKeyringBackend = class _FileKeyringBackend {
2026
2279
  switch (_FileKeyringBackend.slotOf(creds, account)) {
2027
2280
  case "meta": {
2028
2281
  const cookie = _FileKeyringBackend.cookieOf(value);
2029
- if (cookie.sessionId === creds.sessionId && cookie.csrfToken === creds.csrfToken) {
2282
+ const fingerprint = _FileKeyringBackend.fingerprintOf(value);
2283
+ if (cookie.sessionId === creds.sessionId && cookie.csrfToken === creds.csrfToken && fingerprint === (creds.accessApiKeyFingerprint ?? "")) {
2030
2284
  return;
2031
2285
  }
2032
- await writeCredentialsFile(this.path, { ...creds, ...cookie });
2286
+ const next = { ...creds, ...cookie };
2287
+ if (fingerprint !== "") next.accessApiKeyFingerprint = fingerprint;
2288
+ else delete next.accessApiKeyFingerprint;
2289
+ await writeCredentialsFile(this.path, next);
2033
2290
  return;
2034
2291
  }
2035
2292
  case "access":
@@ -8810,7 +9067,7 @@ import { tmpdir as tmpdir2 } from "os";
8810
9067
  import { dirname as dirname3, join as join10 } from "path";
8811
9068
 
8812
9069
  // src/commands/appspace/generate/report.ts
8813
- import { createHash } from "crypto";
9070
+ import { createHash as createHash2 } from "crypto";
8814
9071
  import { mkdir as mkdir3, readFile as readFile7, writeFile as writeFile4 } from "fs/promises";
8815
9072
  import { join as join9 } from "path";
8816
9073
  function reportPath(dir) {
@@ -8858,7 +9115,7 @@ async function ownedFiles(dir) {
8858
9115
  }
8859
9116
  }
8860
9117
  function digestOf(text3) {
8861
- return createHash("sha256").update(text3, "utf8").digest("hex");
9118
+ return createHash2("sha256").update(text3, "utf8").digest("hex");
8862
9119
  }
8863
9120
  async function recordedDigests(dir) {
8864
9121
  try {
@@ -9124,7 +9381,7 @@ function registerGetCommand(parent) {
9124
9381
 
9125
9382
  // src/commands/appspace/generate/catalog.ts
9126
9383
  import { readFile as readFile9 } from "fs/promises";
9127
- import { createHash as createHash2 } from "crypto";
9384
+ import { createHash as createHash3 } from "crypto";
9128
9385
  var NO_CATALOG = {
9129
9386
  source: "none",
9130
9387
  metrics: [],
@@ -17549,6 +17806,7 @@ function validateContract(contract, catalog) {
17549
17806
  if (kind === "dashboard") {
17550
17807
  checkPeriodOverride(doc, problems);
17551
17808
  checkKinds(doc, problems);
17809
+ checkDrillTargets(doc, problems);
17552
17810
  checkPairing(doc, problems);
17553
17811
  checkTransforms(doc, problems);
17554
17812
  } else {
@@ -18030,6 +18288,19 @@ function checkSources(doc, catalog, problems, skipped) {
18030
18288
  }
18031
18289
  report(held, ["data", "widgets", id, "source", key]);
18032
18290
  }
18291
+ {
18292
+ const held = source["period_field"];
18293
+ const field = isObject4(held) ? held["field"] : held;
18294
+ const epoch = isObject4(held) && held["format"] === "epoch";
18295
+ if (typeof field === "string" && dated.has(field.toLowerCase()) && source["period_is_date"] !== true && !epoch) {
18296
+ problems.push({
18297
+ code: "period-needs-date",
18298
+ pointer: pointer("data", "widgets", id, "source", "period_field"),
18299
+ message: `${JSON.stringify(field)} is a date column, and this source does not say so \u2014 its window is not sent to FinanceOS, a drill reads rows from every period, and a clicked chart point matches no row.`,
18300
+ fix: `Add "period_is_date": true to the source (next to period_field ${JSON.stringify(field)}); the page's window is then pushed down as Unix epoch seconds, with no "where" range needed.`
18301
+ });
18302
+ }
18303
+ }
18033
18304
  for (const key of ["select", "dimensions", "measures"]) {
18034
18305
  for (const [index, name] of list2(source[key]).entries()) {
18035
18306
  if (isObject4(name)) {
@@ -18361,6 +18632,40 @@ function checkKinds(doc, problems) {
18361
18632
  });
18362
18633
  }
18363
18634
  }
18635
+ function checkDrillTargets(doc, problems) {
18636
+ const widgets = obj(obj(doc["data"])["widgets"]);
18637
+ for (const [id, raw] of Object.entries(widgets)) {
18638
+ if (id.startsWith("_") || !isObject4(raw)) continue;
18639
+ const row = raw;
18640
+ if (row["data"] !== void 0 && row["data"] !== null) continue;
18641
+ const drill = list2(row["drill"]).filter((name) => typeof name === "string");
18642
+ if (drill.length === 0) continue;
18643
+ const offered = new Set(drill.map((name) => name.toLowerCase()));
18644
+ const listed = JSON.stringify(drill);
18645
+ if (row["kind"] === "breakdown") {
18646
+ const dimension = row["dimension"];
18647
+ if (typeof dimension !== "string" || dimension === "") continue;
18648
+ if (offered.has(dimension.toLowerCase())) continue;
18649
+ problems.push({
18650
+ code: "drill-needs-dimension",
18651
+ pointer: pointer("data", "widgets", id, "drill"),
18652
+ message: `this breakdown is cut by ${JSON.stringify(dimension)} and its drill list ${listed} does not name it, so no legend row answers a click \u2014 the card looks drillable and is not.`,
18653
+ fix: `Put ${JSON.stringify(dimension)} first in drill \u2014 ${JSON.stringify([dimension, ...drill])}: the clicked slice is a value of the card's own dimension, and the levels after it are where the grid goes next.`
18654
+ });
18655
+ continue;
18656
+ }
18657
+ if (row["kind"] !== "table") continue;
18658
+ const declared = list2(row["columns"]).map((column) => isObject4(column) ? column["key"] : void 0).filter((key) => typeof key === "string" && key !== "");
18659
+ const keys2 = declared.length > 0 ? declared : list2(obj(row["source"])["dimensions"]).filter((key) => typeof key === "string");
18660
+ if (keys2.length === 0 || keys2.some((key) => offered.has(key.toLowerCase()))) continue;
18661
+ problems.push({
18662
+ code: "drill-needs-dimension",
18663
+ pointer: pointer("data", "widgets", id, "drill"),
18664
+ message: `this table's drill list ${listed} names none of its columns (${keys2.map((key) => JSON.stringify(key)).join(", ")}), and a cell drills only when its column key is in drill \u2014 so no cell answers a click.`,
18665
+ fix: `Add the column a reader clicks to drill \u2014 e.g. ${JSON.stringify([keys2[0], ...drill])} \u2014 naming the grid's own dimension column first.`
18666
+ });
18667
+ }
18668
+ }
18364
18669
  function checkPairing(doc, problems) {
18365
18670
  const data = doc["data"];
18366
18671
  if (!isObject4(data) || !isObject4(data["widgets"])) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datarails-ai/dr-appspace-cli",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "Datarails App Space CLI — build, try, push and publish App Space apps",
5
5
  "type": "module",
6
6
  "engines": {