nimblybase 0.1.5 → 0.1.7

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 +14 -1
  2. package/dist/index.js +128 -8
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -30,7 +30,14 @@ keychain on macOS, and otherwise in
30
30
  `~/.config/nimblybase/credentials.json` with permissions `0600`. `nimbly
31
31
  login` always says which one it used.
32
32
 
33
- For a machine without a browser, create an access token in the dashboard
33
+ For a machine without a local browser, run `nimbly login --device`. Open the
34
+ displayed verification page on another device and enter the short code. The
35
+ dashboard shows the requesting client and approved workspace/scopes. The CLI
36
+ waits at the server's polling interval, backs off on slow-down, and stops on
37
+ denial or expiry. A failed attempt keeps your previous login. Device sign-in
38
+ still requires a person; it is not unattended CI authentication.
39
+
40
+ For an explicit pasted-token fallback, create an access token in the dashboard
34
41
  under **Access tokens** and run `nimbly login --token <token>`. Omitting the
35
42
  value prompts without echoing it.
36
43
 
@@ -245,3 +252,9 @@ nimbly sql "select * from notes where id = ?" --param 7
245
252
  go into the statement string: a CLI whose only option is to paste them in
246
253
  teaches everybody — and every agent reading a shell history — to write
247
254
  injectable SQL.
255
+
256
+ Public frontend keys can be issued after browser/device login with
257
+ `nimbly keys create "web frontend" --type anon` (requires `projects:write`).
258
+ They obey row and storage policies. `--type service`, key listing and revocation
259
+ require separate session/PAT key-management authority and remain unavailable
260
+ through OAuth. Never put the agent’s OAuth credential in application code.
package/dist/index.js CHANGED
@@ -68,6 +68,7 @@ function parse(argv) {
68
68
  spa: { type: "boolean" },
69
69
  build: { type: "boolean" },
70
70
  "token-only": { type: "boolean" },
71
+ device: { type: "boolean" },
71
72
  off: { type: "boolean" },
72
73
  using: { type: "string" },
73
74
  check: { type: "string" },
@@ -140,6 +141,7 @@ function parse(argv) {
140
141
  spa: v.spa === true ? true : void 0,
141
142
  build: v.build === true ? true : void 0,
142
143
  tokenOnly: v["token-only"] === true ? true : void 0,
144
+ device: v.device === true ? true : void 0,
143
145
  off: v.off === true ? true : void 0,
144
146
  using: v.using,
145
147
  check: v.check,
@@ -182,7 +184,8 @@ USAGE
182
184
  nimbly <command> [options]
183
185
 
184
186
  GETTING STARTED
185
- login Sign in with an access token from the dashboard
187
+ login Sign in through your browser; --device for another machine
188
+ --token-only is the explicit pasted-PAT fallback
186
189
  init [name] Create or explicitly choose a project for this repo
187
190
  describe Show what the linked project's backend can do
188
191
  architect [requirements] Inspect this repo and propose a reviewable architecture
@@ -370,7 +373,7 @@ function when(iso) {
370
373
 
371
374
  // src/api.ts
372
375
  var DEFAULT_API_URL = "https://api-uat.nimblybase.com";
373
- var VERSION = "0.1.5";
376
+ var VERSION = "0.1.7";
374
377
  function dashboardUrl(api) {
375
378
  const match = /^https:\/\/api(-[a-z0-9]+)?\.(.+)$/.exec(api);
376
379
  return match ? `https://app${match[1] ?? ""}.${match[2]}` : null;
@@ -470,8 +473,8 @@ function createClient(base, token) {
470
473
  resumeProject: (id) => req(`/v1/projects/${id}/resume`, { method: "POST", body: "{}" }),
471
474
  keys: (projectId) => req(`/v1/projects/${projectId}/keys`),
472
475
  createKey: (projectId, input) => req(
473
- `/v1/projects/${projectId}/keys`,
474
- { method: "POST", body: JSON.stringify(input) }
476
+ `/v1/projects/${projectId}/${input.type === "anon" ? "application-keys" : "keys"}`,
477
+ { method: "POST", body: JSON.stringify(input.type === "anon" ? { name: input.name } : input) }
475
478
  ),
476
479
  revokeKey: (keyId) => req(`/v1/keys/${keyId}`, { method: "DELETE" }),
477
480
  // --- project data ---------------------------------------------------
@@ -660,7 +663,7 @@ async function discover(issuer, fetchImpl = fetch) {
660
663
  if (!doc.authorization_endpoint || !doc.token_endpoint) {
661
664
  throw new Error("the authorization server metadata is missing an endpoint this flow needs");
662
665
  }
663
- for (const endpoint of [doc.token_endpoint, doc.registration_endpoint].filter(Boolean)) {
666
+ for (const endpoint of [doc.token_endpoint, doc.registration_endpoint, doc.device_authorization_endpoint].filter(Boolean)) {
664
667
  const target = new URL(endpoint);
665
668
  if (target.origin !== origin || target.username || target.password || target.hash) {
666
669
  throw new Error("the authorization server advertises a credential endpoint outside its API origin");
@@ -782,6 +785,57 @@ function grantResponse(body, clientId) {
782
785
  scopes: String(body.scope ?? "").split(" ").filter(Boolean)
783
786
  };
784
787
  }
788
+ async function deviceLogin(input, fetchImpl = fetch) {
789
+ const doc = await discover(input.issuer, fetchImpl);
790
+ if (!doc.device_authorization_endpoint || !doc.registration_endpoint) {
791
+ throw new Error("this server does not offer device sign-in and client registration");
792
+ }
793
+ const clientId = await registerClient(doc.registration_endpoint, fetchImpl);
794
+ const start = await fetchImpl(doc.device_authorization_endpoint, {
795
+ method: "POST",
796
+ redirect: "error",
797
+ signal: AbortSignal.timeout(3e4),
798
+ headers: { "content-type": "application/x-www-form-urlencoded" },
799
+ body: new URLSearchParams({ client_id: clientId }).toString()
800
+ });
801
+ if (!start.ok) throw new Error("device sign-in could not be started");
802
+ const device = await start.json();
803
+ if (typeof device.device_code !== "string" || !/^nb_oat_[A-Za-z0-9_-]+$/.test(device.device_code) || typeof device.user_code !== "string" || !/^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(device.user_code) || typeof device.verification_uri !== "string" || typeof device.expires_in !== "number" || !Number.isInteger(device.expires_in) || device.expires_in < 1 || device.expires_in > 900 || device.interval !== void 0 && (!Number.isInteger(device.interval) || device.interval < 1 || device.interval > 60)) {
804
+ throw new Error("the server returned an invalid device authorization response");
805
+ }
806
+ const verification = new URL(device.verification_uri);
807
+ if (verification.origin !== new URL(doc.authorization_endpoint).origin || verification.username || verification.password || verification.search || verification.hash) {
808
+ throw new Error("the device verification page is outside the authorization origin");
809
+ }
810
+ const now = input.now ?? (() => performance.now());
811
+ const sleep = input.sleep ?? ((ms) => new Promise((resolve8) => setTimeout(resolve8, ms)));
812
+ const deadline = now() + device.expires_in * 1e3;
813
+ let interval = Math.max(5, device.interval ?? 5) * 1e3;
814
+ input.announce({ verificationUri: verification.toString(), userCode: device.user_code, expiresInSeconds: device.expires_in });
815
+ while (now() < deadline) {
816
+ await sleep(Math.min(interval, Math.max(0, deadline - now())));
817
+ const remaining = deadline - now();
818
+ if (remaining <= 0) break;
819
+ const poll = await fetchImpl(doc.token_endpoint, {
820
+ method: "POST",
821
+ redirect: "error",
822
+ signal: AbortSignal.timeout(Math.min(3e4, remaining)),
823
+ headers: { "content-type": "application/x-www-form-urlencoded" },
824
+ body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", client_id: clientId, device_code: device.device_code }).toString()
825
+ });
826
+ const body = await poll.json();
827
+ if (poll.ok) return grantResponse(body, clientId);
828
+ if (body.error === "authorization_pending") continue;
829
+ if (body.error === "slow_down") {
830
+ interval += 5e3;
831
+ continue;
832
+ }
833
+ if (body.error === "access_denied") throw new Error("you declined device sign-in; your previous login was kept");
834
+ if (body.error === "expired_token") break;
835
+ throw new Error("device sign-in failed; your previous login was kept");
836
+ }
837
+ throw new Error("device sign-in expired; your previous login was kept");
838
+ }
785
839
  async function refreshGrant(input, fetchImpl = fetch) {
786
840
  const res = await fetchImpl(input.tokenEndpoint, {
787
841
  method: "POST",
@@ -1269,8 +1323,17 @@ var WHERE_SAID = {
1269
1323
  async function login(flags) {
1270
1324
  const base = apiUrl(flags.api);
1271
1325
  const given = flags.token ?? await readPipedInput();
1326
+ if (flags.device && (given || flags.tokenOnly)) throw new CliError("Choose device sign-in or a pasted token, not both.", EXIT.usage);
1272
1327
  if (!given && !flags.tokenOnly) {
1273
- const grant = await browserLogin({
1328
+ const grant = flags.device ? await deviceLogin({
1329
+ issuer: base,
1330
+ announce: ({ verificationUri, userCode, expiresInSeconds }) => {
1331
+ note(`On another device, open:
1332
+ ${verificationUri}
1333
+ Enter code: ${userCode}
1334
+ It expires in ${expiresInSeconds} seconds. Approve only the CLI request you started.`);
1335
+ }
1336
+ }) : await browserLogin({
1274
1337
  issuer: base,
1275
1338
  announce: (url) => {
1276
1339
  out("Opening your browser to sign in.");
@@ -5522,6 +5585,7 @@ var PendingInvitation = external_exports.object({
5522
5585
  expiresAt: IsoDateTime
5523
5586
  });
5524
5587
  var Me = external_exports.object({
5588
+ authorization: external_exports.object({ via: external_exports.enum(["session", "token", "oauth"]), projectId: external_exports.string().uuid().nullable() }).optional(),
5525
5589
  user: external_exports.object({ id: Uuid, clerkUserId: external_exports.string() }),
5526
5590
  organization: external_exports.object({ id: Uuid, name: external_exports.string(), slug: external_exports.string(), role: Role }).nullable(),
5527
5591
  permissions: external_exports.array(external_exports.string())
@@ -5602,6 +5666,9 @@ var BootstrapBody = external_exports.object({
5602
5666
  }).refine((body) => !(body.planId || body.factsFingerprint) || Boolean(body.name), {
5603
5667
  message: "planId and factsFingerprint are only valid when creating a named project"
5604
5668
  });
5669
+ var CreateApplicationKeyBody = external_exports.object({
5670
+ name: external_exports.string().min(1).max(100)
5671
+ }).strict();
5605
5672
  var CreateApiKeyBody = external_exports.object({
5606
5673
  name: external_exports.string().min(1).max(100),
5607
5674
  type: ApiKeyType
@@ -5803,6 +5870,20 @@ var PendingInvitationListResponse = external_exports.object({ invitations: exter
5803
5870
  var RedeemedInvitationResponse = external_exports.object({
5804
5871
  organization: external_exports.object({ id: Uuid, role: Role })
5805
5872
  });
5873
+ var OAuthConnection = external_exports.object({
5874
+ id: external_exports.string().uuid(),
5875
+ clientId: external_exports.string().uuid(),
5876
+ clientName: external_exports.string(),
5877
+ orgId: external_exports.string().uuid(),
5878
+ projectId: external_exports.string().uuid().nullable(),
5879
+ projectName: external_exports.string().nullable(),
5880
+ scopes: external_exports.array(external_exports.string()),
5881
+ status: external_exports.enum(["ready", "awaiting_exchange"]),
5882
+ connectedAt: external_exports.string().datetime(),
5883
+ expiresAt: external_exports.string().datetime(),
5884
+ lastUsedAt: external_exports.string().datetime().nullable()
5885
+ });
5886
+ var OAuthConnectionListResponse = external_exports.object({ enabled: external_exports.boolean(), connections: external_exports.array(OAuthConnection) });
5806
5887
 
5807
5888
  // ../../node_modules/.pnpm/zod-to-json-schema@3.25.2_zod@3.25.76/node_modules/zod-to-json-schema/dist/esm/Options.js
5808
5889
  var ignoreOverride = Symbol("Let zodToJsonSchema decide on which parser to use");
@@ -5990,6 +6071,34 @@ var routes = {
5990
6071
  // All three are sessionOnly: a token must not be able to mint another
5991
6072
  // token. Otherwise a leaked one manufactures replacements faster than
5992
6073
  // anyone can revoke them, and revoking the one you found achieves nothing.
6074
+ listOAuthConnections: {
6075
+ method: "GET",
6076
+ path: "/v1/me/connections",
6077
+ summary: "Your authorized app connections in this workspace. No credentials.",
6078
+ permission: "org:read",
6079
+ sessionOnly: true,
6080
+ status: 200,
6081
+ response: OAuthConnectionListResponse
6082
+ },
6083
+ narrowOAuthConnection: {
6084
+ method: "POST",
6085
+ path: "/v1/me/connections/{id}/project",
6086
+ summary: "Permanently restrict one authorization to an existing project. Cannot widen or retarget.",
6087
+ permission: "org:read",
6088
+ sessionOnly: true,
6089
+ params: Id,
6090
+ body: external_exports.object({ projectId: external_exports.string().uuid() }).strict(),
6091
+ status: 204
6092
+ },
6093
+ revokeOAuthConnection: {
6094
+ method: "DELETE",
6095
+ path: "/v1/me/connections/{id}",
6096
+ summary: "Disconnect one consent family, including access, refresh and pending exchange.",
6097
+ permission: "org:read",
6098
+ sessionOnly: true,
6099
+ params: Id,
6100
+ status: 204
6101
+ },
5993
6102
  listAccessTokens: {
5994
6103
  method: "GET",
5995
6104
  path: "/v1/me/tokens",
@@ -6153,6 +6262,16 @@ var routes = {
6153
6262
  status: 200,
6154
6263
  response: ApiKeyListResponse
6155
6264
  },
6265
+ createApplicationKey: {
6266
+ method: "POST",
6267
+ path: "/v1/projects/{id}/application-keys",
6268
+ summary: "Issue a public anon application key. It obeys row and storage policies; never issues a service key.",
6269
+ permission: "project:update",
6270
+ params: Id,
6271
+ body: CreateApplicationKeyBody,
6272
+ status: 201,
6273
+ response: IssuedApiKeyResponse
6274
+ },
6156
6275
  createProjectKey: {
6157
6276
  method: "POST",
6158
6277
  path: "/v1/projects/{id}/keys",
@@ -6173,8 +6292,9 @@ var routes = {
6173
6292
  // The platform token never leaves the control plane and expires after 60
6174
6293
  // seconds. Callers are therefore authorized for the operation they asked
6175
6294
  // for (`data:read`, `data:write`, `deploy`, or `logs:read`) rather than for
6176
- // the internal credential used to carry it out. Actual API-key management
6177
- // remains under apikey:* and is deliberately closed to OAuth.
6295
+ // the internal credential used to carry it out. Service-key issuance, key
6296
+ // listing and revocation remain under apikey:* and closed to OAuth. The
6297
+ // separate anon-only application-key route above uses project:update.
6178
6298
  getProjectSchema: {
6179
6299
  method: "GET",
6180
6300
  path: "/v1/projects/{id}/schema",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nimblybase",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "The NimblyBase command line - projects, keys and backends from a terminal.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {