nimblybase 0.1.4 → 0.1.5

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 (3) hide show
  1. package/README.md +9 -0
  2. package/dist/index.js +98 -44
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -37,6 +37,15 @@ value prompts without echoing it.
37
37
  In CI, set `NIMBLYBASE_TOKEN` instead. It takes precedence over anything
38
38
  stored and is never written to disk.
39
39
 
40
+ Stored browser grants and newly saved access tokens stay bound to the API used
41
+ at login. A repository link, `--api`, or environment setting cannot retarget that
42
+ credential. To switch servers, explicitly run `nimbly login --api <destination>`;
43
+ the previous login is kept if verification fails. Older bare tokens remain usable
44
+ on the original UAT default until you log in again to bind another destination.
45
+ An injected CI token requires an explicit `NIMBLYBASE_API_URL`/`--api` when a
46
+ repository selects a different API. It never refreshes a separate personal login.
47
+ Use HTTPS; local development may use literal `http://127.0.0.1:<port>` or `[::1]`.
48
+
40
49
  ## Requirements before creating
41
50
 
42
51
  For a new empty application, choose `--recipe static-d1` explicitly when a static
package/dist/index.js CHANGED
@@ -370,19 +370,31 @@ function when(iso) {
370
370
 
371
371
  // src/api.ts
372
372
  var DEFAULT_API_URL = "https://api-uat.nimblybase.com";
373
- var VERSION = "0.1.4";
373
+ var VERSION = "0.1.5";
374
374
  function dashboardUrl(api) {
375
375
  const match = /^https:\/\/api(-[a-z0-9]+)?\.(.+)$/.exec(api);
376
376
  return match ? `https://app${match[1] ?? ""}.${match[2]}` : null;
377
377
  }
378
378
  function apiUrl(override) {
379
- return (override ?? process.env.NIMBLYBASE_API_URL ?? DEFAULT_API_URL).replace(/\/+$/, "");
379
+ const raw = override ?? process.env.NIMBLYBASE_API_URL ?? DEFAULT_API_URL;
380
+ let parsed;
381
+ try {
382
+ parsed = new URL(raw);
383
+ } catch {
384
+ throw new CliError("Invalid API address.", EXIT.usage);
385
+ }
386
+ const loopback = ["127.0.0.1", "[::1]"].includes(parsed.hostname);
387
+ if (parsed.username || parsed.password || parsed.search || parsed.hash || !/^\/*$/.test(parsed.pathname) || parsed.protocol !== "https:" && !(parsed.protocol === "http:" && loopback)) {
388
+ throw new CliError("The API must be an HTTPS origin without credentials, path, query or fragment. HTTP is allowed only on a literal loopback address.", EXIT.usage);
389
+ }
390
+ return parsed.origin;
380
391
  }
381
392
  async function request(base, token, path, init2 = {}) {
382
393
  let res;
383
394
  try {
384
395
  res = await fetch(`${base}${path}`, {
385
396
  ...init2,
397
+ redirect: "error",
386
398
  headers: {
387
399
  authorization: `Bearer ${token}`,
388
400
  ...init2.body ? { "content-type": "application/json" } : {},
@@ -638,8 +650,9 @@ function pkcePair() {
638
650
  return { verifier, challenge: b64url(createHash("sha256").update(verifier).digest()) };
639
651
  }
640
652
  async function discover(issuer, fetchImpl = fetch) {
641
- const url = new URL("/.well-known/oauth-authorization-server", issuer).toString();
642
- const res = await fetchImpl(url);
653
+ const origin = apiUrl(issuer);
654
+ const url = new URL("/.well-known/oauth-authorization-server", origin).toString();
655
+ const res = await fetchImpl(url, { redirect: "error", signal: AbortSignal.timeout(3e4) });
643
656
  if (!res.ok) {
644
657
  throw new Error(`${issuer} does not advertise an authorization server (${res.status} from ${url})`);
645
658
  }
@@ -647,11 +660,23 @@ async function discover(issuer, fetchImpl = fetch) {
647
660
  if (!doc.authorization_endpoint || !doc.token_endpoint) {
648
661
  throw new Error("the authorization server metadata is missing an endpoint this flow needs");
649
662
  }
663
+ for (const endpoint of [doc.token_endpoint, doc.registration_endpoint].filter(Boolean)) {
664
+ const target = new URL(endpoint);
665
+ if (target.origin !== origin || target.username || target.password || target.hash) {
666
+ throw new Error("the authorization server advertises a credential endpoint outside its API origin");
667
+ }
668
+ }
669
+ const authorize = new URL(doc.authorization_endpoint);
670
+ if (authorize.username || authorize.password || authorize.hash || authorize.protocol !== "https:" && !(authorize.protocol === "http:" && ["127.0.0.1", "[::1]"].includes(authorize.hostname))) {
671
+ throw new Error("the authorization endpoint must use HTTPS, or literal loopback HTTP");
672
+ }
650
673
  return doc;
651
674
  }
652
675
  async function registerClient(registrationEndpoint, fetchImpl = fetch) {
653
676
  const res = await fetchImpl(registrationEndpoint, {
654
677
  method: "POST",
678
+ redirect: "error",
679
+ signal: AbortSignal.timeout(3e4),
655
680
  headers: { "content-type": "application/json" },
656
681
  body: JSON.stringify({
657
682
  client_name: "NimblyBase CLI",
@@ -730,6 +755,8 @@ function openBrowser(url) {
730
755
  async function exchange(input, fetchImpl = fetch) {
731
756
  const res = await fetchImpl(input.tokenEndpoint, {
732
757
  method: "POST",
758
+ redirect: "error",
759
+ signal: AbortSignal.timeout(3e4),
733
760
  headers: { "content-type": "application/x-www-form-urlencoded" },
734
761
  body: new URLSearchParams({
735
762
  grant_type: "authorization_code",
@@ -741,19 +768,25 @@ async function exchange(input, fetchImpl = fetch) {
741
768
  });
742
769
  const body = await res.json();
743
770
  if (!res.ok) throw new Error(`the sign-in could not be completed (${body.error ?? res.status})`);
771
+ return grantResponse(body, input.clientId);
772
+ }
773
+ function grantResponse(body, clientId) {
774
+ if (typeof body.access_token !== "string" || !/^nb_oat_[A-Za-z0-9_-]+$/.test(body.access_token) || typeof body.refresh_token !== "string" || !/^nb_ort_[A-Za-z0-9_-]+$/.test(body.refresh_token) || typeof body.expires_in !== "number" || !Number.isInteger(body.expires_in) || body.expires_in < 1 || body.expires_in > 900 || body.token_type !== void 0 && body.token_type !== "Bearer" || body.scope !== void 0 && typeof body.scope !== "string") {
775
+ throw new Error("the authorization server returned an invalid credential response");
776
+ }
744
777
  return {
745
- accessToken: String(body.access_token),
746
- refreshToken: String(body.refresh_token),
747
- // Ours, not theirs: a clock ahead of the server's shortens the
748
- // window, which is the direction that fails safe.
749
- expiresAt: Date.now() + Number(body.expires_in ?? 900) * 1e3,
750
- clientId: input.clientId,
778
+ accessToken: body.access_token,
779
+ refreshToken: body.refresh_token,
780
+ expiresAt: Date.now() + body.expires_in * 1e3,
781
+ clientId,
751
782
  scopes: String(body.scope ?? "").split(" ").filter(Boolean)
752
783
  };
753
784
  }
754
785
  async function refreshGrant(input, fetchImpl = fetch) {
755
786
  const res = await fetchImpl(input.tokenEndpoint, {
756
787
  method: "POST",
788
+ redirect: "error",
789
+ signal: AbortSignal.timeout(3e4),
757
790
  headers: { "content-type": "application/x-www-form-urlencoded" },
758
791
  body: new URLSearchParams({
759
792
  grant_type: "refresh_token",
@@ -763,13 +796,7 @@ async function refreshGrant(input, fetchImpl = fetch) {
763
796
  });
764
797
  const body = await res.json();
765
798
  if (!res.ok) throw new Error(String(body.error ?? res.status));
766
- return {
767
- accessToken: String(body.access_token),
768
- refreshToken: String(body.refresh_token),
769
- expiresAt: Date.now() + Number(body.expires_in ?? 900) * 1e3,
770
- clientId: input.clientId,
771
- scopes: String(body.scope ?? "").split(" ").filter(Boolean)
772
- };
799
+ return grantResponse(body, input.clientId);
773
800
  }
774
801
  async function browserLogin(input) {
775
802
  const doc = await discover(input.issuer);
@@ -823,7 +850,8 @@ import { execFile } from "node:child_process";
823
850
  import { promisify } from "node:util";
824
851
  import { homedir } from "node:os";
825
852
  import { join, dirname } from "node:path";
826
- import { mkdir, readFile, writeFile, chmod, rm, stat } from "node:fs/promises";
853
+ import { mkdir, readFile, writeFile, chmod, rm, stat, rename } from "node:fs/promises";
854
+ import { randomUUID } from "node:crypto";
827
855
  var run = promisify(execFile);
828
856
  var SERVICE = "nimblybase-cli";
829
857
  var ACCOUNT = "access-token";
@@ -874,15 +902,16 @@ async function keychainDelete() {
874
902
  return false;
875
903
  }
876
904
  }
877
- function encodeSecret(value) {
878
- return typeof value === "string" ? value : JSON.stringify(value);
905
+ function encodeSecret(value, apiUrl2) {
906
+ return typeof value === "string" ? apiUrl2 ? JSON.stringify({ token: value, apiUrl: apiUrl2 }) : value : JSON.stringify(value);
879
907
  }
880
908
  function decodeSecret(raw) {
881
909
  const trimmed = raw.trim();
882
910
  if (!trimmed.startsWith("{")) return { token: trimmed };
883
911
  try {
884
912
  const parsed = JSON.parse(trimmed);
885
- return parsed.refreshToken ? { grant: parsed } : {};
913
+ if (parsed.refreshToken) return { grant: parsed };
914
+ return typeof parsed.token === "string" && typeof parsed.apiUrl === "string" ? { token: parsed.token, apiUrl: parsed.apiUrl } : {};
886
915
  } catch {
887
916
  return {};
888
917
  }
@@ -894,18 +923,25 @@ async function fileRead() {
894
923
  return null;
895
924
  }
896
925
  }
897
- async function fileWrite(value) {
926
+ async function fileWrite(value, apiUrl2) {
898
927
  const path = credentialsPath();
899
928
  await mkdir(dirname(path), { recursive: true, mode: 448 });
900
- const body = typeof value === "string" ? { token: value } : { grant: value };
901
- await writeFile(path, `${JSON.stringify(body, null, 2)}
902
- `, { mode: 384 });
903
- await chmod(path, 384);
929
+ await chmod(dirname(path), 448);
930
+ const body = typeof value === "string" ? { token: value, ...apiUrl2 ? { apiUrl: apiUrl2 } : {} } : { grant: value };
931
+ const temporary = `${path}.${randomUUID()}.tmp`;
932
+ try {
933
+ await writeFile(temporary, `${JSON.stringify(body, null, 2)}
934
+ `, { mode: 384, flag: "wx" });
935
+ await chmod(temporary, 384);
936
+ await rename(temporary, path);
937
+ } finally {
938
+ await rm(temporary, { force: true });
939
+ }
904
940
  }
905
941
  var credentials = {
906
- async save(token) {
907
- if (await keychainSet(token)) return "keychain";
908
- await fileWrite(token);
942
+ async save(token, apiUrl2) {
943
+ if (await keychainSet(encodeSecret(token, apiUrl2))) return "keychain";
944
+ await fileWrite(token, apiUrl2);
909
945
  return "file";
910
946
  },
911
947
  async saveGrant(grant) {
@@ -918,19 +954,21 @@ var credentials = {
918
954
  if (fromEnv) return { token: fromEnv, where: "environment" };
919
955
  const fromKeychain = await keychainGet();
920
956
  if (fromKeychain) {
921
- const { token, grant } = decodeSecret(fromKeychain);
957
+ const { token, grant, apiUrl: apiUrl3 } = decodeSecret(fromKeychain);
922
958
  const value2 = token ?? grant?.accessToken;
923
- if (value2) return { token: value2, where: "keychain" };
959
+ if (value2) return { token: value2, where: "keychain", ...apiUrl3 || grant?.issuer ? { apiUrl: apiUrl3 ?? grant.issuer } : {} };
924
960
  }
925
961
  const fromFile = await fileRead();
926
962
  const value = fromFile?.token ?? fromFile?.grant?.accessToken;
927
- return value ? { token: value, where: "file" } : null;
963
+ const apiUrl2 = fromFile?.apiUrl ?? fromFile?.grant?.issuer;
964
+ return value ? { token: value, where: "file", ...apiUrl2 ? { apiUrl: apiUrl2 } : {} } : null;
928
965
  },
929
966
  async loadGrant() {
967
+ if (process.env.NIMBLYBASE_TOKEN?.trim()) return null;
930
968
  const fromKeychain = await keychainGet();
931
969
  if (fromKeychain) {
932
970
  const { grant } = decodeSecret(fromKeychain);
933
- if (grant) return { grant, where: "keychain" };
971
+ return grant ? { grant, where: "keychain" } : null;
934
972
  }
935
973
  const fromFile = await fileRead();
936
974
  return fromFile?.grant ? { grant: fromFile.grant, where: "file" } : null;
@@ -1006,8 +1044,8 @@ async function confirm(question) {
1006
1044
  }
1007
1045
 
1008
1046
  // src/link.ts
1009
- import { readFile as readFile2, rename, rm as rm2, writeFile as writeFile2, link as hardLink } from "node:fs/promises";
1010
- import { randomUUID } from "node:crypto";
1047
+ import { readFile as readFile2, rename as rename2, rm as rm2, writeFile as writeFile2, link as hardLink } from "node:fs/promises";
1048
+ import { randomUUID as randomUUID2 } from "node:crypto";
1011
1049
  import { dirname as dirname2, join as join2, parse as parse2, resolve } from "node:path";
1012
1050
  var LINK_FILE = "nimblybase.json";
1013
1051
  var BOOTSTRAP_FILE = ".nimblybase.bootstrap.json";
@@ -1089,11 +1127,11 @@ async function writeLink(dir, link2, options = {}) {
1089
1127
  slug: link2.slug
1090
1128
  };
1091
1129
  if (link2.apiUrl !== void 0) next.apiUrl = link2.apiUrl;
1092
- const temporary = join2(dirname2(path), `.${LINK_FILE}.${process.pid}.${randomUUID()}.tmp`);
1130
+ const temporary = join2(dirname2(path), `.${LINK_FILE}.${process.pid}.${randomUUID2()}.tmp`);
1093
1131
  try {
1094
1132
  await writeFile2(temporary, `${JSON.stringify(next, null, 2)}
1095
1133
  `, { flag: "wx" });
1096
- await rename(temporary, path);
1134
+ await rename2(temporary, path);
1097
1135
  } catch (err) {
1098
1136
  await rm2(temporary, { force: true });
1099
1137
  throw err;
@@ -1122,8 +1160,8 @@ async function readPendingBootstrap(dir) {
1122
1160
  }
1123
1161
  async function pendingBootstrap(dir, input) {
1124
1162
  const path = join2(dir, BOOTSTRAP_FILE);
1125
- const temporary = join2(dir, `.nimblybase.bootstrap.${process.pid}.${randomUUID()}.tmp`);
1126
- const pending = { schemaVersion: 2, requestId: randomUUID(), ...input };
1163
+ const temporary = join2(dir, `.nimblybase.bootstrap.${process.pid}.${randomUUID2()}.tmp`);
1164
+ const pending = { schemaVersion: 2, requestId: randomUUID2(), ...input };
1127
1165
  try {
1128
1166
  await writeFile2(temporary, `${JSON.stringify(pending, null, 2)}
1129
1167
  `, { flag: "wx", mode: 384 });
@@ -1147,8 +1185,24 @@ async function clearPendingBootstrap(dir) {
1147
1185
  // src/context.ts
1148
1186
  async function context(apiOverride) {
1149
1187
  const link2 = await findLink();
1150
- const base = apiUrl(apiOverride ?? process.env.NIMBLYBASE_API_URL ?? link2?.apiUrl);
1188
+ const storedBeforeRefresh = await credentials.load();
1189
+ const base = apiUrl(apiOverride ?? process.env.NIMBLYBASE_API_URL ?? link2?.apiUrl ?? (storedBeforeRefresh?.where !== "environment" ? storedBeforeRefresh?.apiUrl : void 0));
1151
1190
  const held = await credentials.loadGrant();
1191
+ if (storedBeforeRefresh && storedBeforeRefresh.where !== "environment") {
1192
+ const bound = apiUrl(storedBeforeRefresh.apiUrl ?? "https://api-uat.nimblybase.com");
1193
+ if (base !== bound) throw new CliError(
1194
+ "This credential belongs to a different API.",
1195
+ EXIT.unauthenticated,
1196
+ "Run `nimbly login --api <destination>` to authorize that API. Your stored login was kept."
1197
+ );
1198
+ }
1199
+ if (storedBeforeRefresh?.where === "environment" && link2?.apiUrl && !apiOverride && !process.env.NIMBLYBASE_API_URL && base !== apiUrl(DEFAULT_API_URL)) {
1200
+ throw new CliError(
1201
+ "An environment token cannot be sent to an API selected only by a repository file.",
1202
+ EXIT.usage,
1203
+ "Set NIMBLYBASE_API_URL or pass --api explicitly for this automation identity."
1204
+ );
1205
+ }
1152
1206
  if (held && held.grant.expiresAt - Date.now() < 6e4) {
1153
1207
  try {
1154
1208
  const fresh = await refreshGrant({
@@ -1225,8 +1279,8 @@ async function login(flags) {
1225
1279
  },
1226
1280
  open: openBrowser
1227
1281
  });
1228
- const where2 = await credentials.saveGrant({ ...grant, issuer: base });
1229
1282
  const me2 = await createClient(base, grant.accessToken).me();
1283
+ const where2 = await credentials.saveGrant({ ...grant, issuer: base });
1230
1284
  if (isJsonMode()) {
1231
1285
  json({ ok: true, storedIn: where2, organization: me2.organization, api: base, scopes: grant.scopes });
1232
1286
  return;
@@ -1246,7 +1300,7 @@ async function login(flags) {
1246
1300
  );
1247
1301
  }
1248
1302
  const me = await createClient(base, token).me();
1249
- const where = await credentials.save(token);
1303
+ const where = await credentials.save(token, base);
1250
1304
  if (isJsonMode()) {
1251
1305
  json({ ok: true, storedIn: where, organization: me.organization, api: base });
1252
1306
  return;
@@ -7849,7 +7903,7 @@ async function mcp(flags, which) {
7849
7903
  }
7850
7904
 
7851
7905
  // src/commands/db.ts
7852
- import { randomUUID as randomUUID2 } from "node:crypto";
7906
+ import { randomUUID as randomUUID3 } from "node:crypto";
7853
7907
  import { readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
7854
7908
  import { dirname as dirname3, isAbsolute, join as join4, resolve as resolve5 } from "node:path";
7855
7909
  var DEFAULT_SCHEMA_FILE = "schema.json";
@@ -7886,7 +7940,7 @@ var lineageOf = (parsed) => {
7886
7940
  const raw = parsed?.lineage;
7887
7941
  return typeof raw === "string" && raw.trim() !== "" ? raw.trim() : void 0;
7888
7942
  };
7889
- var newLineage = () => `cli-${randomUUID2()}`;
7943
+ var newLineage = () => `cli-${randomUUID3()}`;
7890
7944
  async function ensureLineage(path, parsed) {
7891
7945
  const existing = lineageOf(parsed);
7892
7946
  if (existing) return existing;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nimblybase",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "The NimblyBase command line - projects, keys and backends from a terminal.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {