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.
- package/README.md +14 -1
- package/dist/index.js +128 -8
- 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,
|
|
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
|
|
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.
|
|
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}
|
|
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
|
|
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.
|
|
6177
|
-
//
|
|
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",
|