@devdogsuga/devtools 0.1.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.
- package/README.md +44 -0
- package/bin/devtools-ci-bare.mjs +48 -0
- package/bin/devtools-ci.mjs +17 -0
- package/bin/devtools.mjs +27 -0
- package/dist/args.d.ts +26 -0
- package/dist/args.js +78 -0
- package/dist/bws/bw.d.ts +9 -0
- package/dist/bws/bw.js +49 -0
- package/dist/bws/client.d.ts +43 -0
- package/dist/bws/client.js +363 -0
- package/dist/bws/environments.d.ts +96 -0
- package/dist/bws/environments.js +111 -0
- package/dist/bws/pace.d.ts +27 -0
- package/dist/bws/pace.js +43 -0
- package/dist/bws/retry.d.ts +12 -0
- package/dist/bws/retry.js +39 -0
- package/dist/bws/store.d.ts +1 -0
- package/dist/bws/store.js +32 -0
- package/dist/bws/token.d.ts +31 -0
- package/dist/bws/token.js +88 -0
- package/dist/bws/vault.d.ts +29 -0
- package/dist/bws/vault.js +200 -0
- package/dist/catalog.d.ts +55 -0
- package/dist/catalog.js +81 -0
- package/dist/cf/commands.d.ts +1 -0
- package/dist/cf/commands.js +182 -0
- package/dist/cf/local-env.d.ts +44 -0
- package/dist/cf/local-env.js +148 -0
- package/dist/ci.d.ts +1 -0
- package/dist/ci.js +319 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +1077 -0
- package/dist/commands.d.ts +255 -0
- package/dist/commands.js +1279 -0
- package/dist/completions.d.ts +4 -0
- package/dist/completions.js +110 -0
- package/dist/cron/commands.d.ts +47 -0
- package/dist/cron/commands.js +372 -0
- package/dist/cron/discovery.d.ts +55 -0
- package/dist/cron/discovery.js +85 -0
- package/dist/cron/schema.d.ts +45 -0
- package/dist/cron/schema.js +35 -0
- package/dist/db/config-push.d.ts +8 -0
- package/dist/db/config-push.js +11 -0
- package/dist/db/connection.d.ts +79 -0
- package/dist/db/connection.js +114 -0
- package/dist/db/exec.d.ts +1 -0
- package/dist/db/exec.js +11 -0
- package/dist/db/generate-migration.d.ts +1 -0
- package/dist/db/generate-migration.js +67 -0
- package/dist/db/generate-types.d.ts +1 -0
- package/dist/db/generate-types.js +4 -0
- package/dist/db/introspect.d.ts +1 -0
- package/dist/db/introspect.js +113 -0
- package/dist/db/local-env.d.ts +65 -0
- package/dist/db/local-env.js +23 -0
- package/dist/db/new-migration.d.ts +1 -0
- package/dist/db/new-migration.js +8 -0
- package/dist/db/run.d.ts +70 -0
- package/dist/db/run.js +158 -0
- package/dist/db/seed-buckets.d.ts +2 -0
- package/dist/db/seed-buckets.js +4 -0
- package/dist/db/seed-production.d.ts +8 -0
- package/dist/db/seed-production.js +73 -0
- package/dist/db/seed-roles.d.ts +7 -0
- package/dist/db/seed-roles.js +31 -0
- package/dist/db/session-refresh.d.ts +43 -0
- package/dist/db/session-refresh.js +40 -0
- package/dist/deploy/migrations.d.ts +20 -0
- package/dist/deploy/migrations.js +68 -0
- package/dist/deploy/orphans.d.ts +20 -0
- package/dist/deploy/orphans.js +177 -0
- package/dist/deploy/preflight.d.ts +53 -0
- package/dist/deploy/preflight.js +189 -0
- package/dist/deploy/report.d.ts +26 -0
- package/dist/deploy/report.js +70 -0
- package/dist/deploy/require-planner.d.ts +2 -0
- package/dist/deploy/require-planner.js +60 -0
- package/dist/deploy/require-token.d.ts +15 -0
- package/dist/deploy/require-token.js +78 -0
- package/dist/deploy/secrets-file.d.ts +17 -0
- package/dist/deploy/secrets-file.js +167 -0
- package/dist/deploy/write-env.d.ts +40 -0
- package/dist/deploy/write-env.js +393 -0
- package/dist/docs/index-pages.d.ts +36 -0
- package/dist/docs/index-pages.js +184 -0
- package/dist/doctor.d.ts +62 -0
- package/dist/doctor.js +225 -0
- package/dist/emails/commands.d.ts +15 -0
- package/dist/emails/commands.js +130 -0
- package/dist/emails/email-types.d.ts +13 -0
- package/dist/emails/email-types.js +1 -0
- package/dist/emails/fixtures.d.ts +20 -0
- package/dist/emails/fixtures.js +20 -0
- package/dist/env/audit.d.ts +206 -0
- package/dist/env/audit.js +436 -0
- package/dist/env/cloudflare.d.ts +21 -0
- package/dist/env/cloudflare.js +61 -0
- package/dist/env/commands.d.ts +99 -0
- package/dist/env/commands.js +612 -0
- package/dist/env/discovery.d.ts +15 -0
- package/dist/env/discovery.js +226 -0
- package/dist/env/document.d.ts +119 -0
- package/dist/env/document.js +323 -0
- package/dist/env/example.d.ts +68 -0
- package/dist/env/example.js +603 -0
- package/dist/env/selection.d.ts +105 -0
- package/dist/env/selection.js +226 -0
- package/dist/environment.d.ts +92 -0
- package/dist/environment.js +206 -0
- package/dist/fingerprint.d.ts +10 -0
- package/dist/fingerprint.js +18 -0
- package/dist/gen/campus-map.d.ts +1 -0
- package/dist/gen/campus-map.js +473 -0
- package/dist/gen/commands.d.ts +1 -0
- package/dist/gen/commands.js +25 -0
- package/dist/gen/hypno.d.ts +1 -0
- package/dist/gen/hypno.js +134 -0
- package/dist/gh/client.d.ts +80 -0
- package/dist/gh/client.js +258 -0
- package/dist/gh/environments.d.ts +83 -0
- package/dist/gh/environments.js +178 -0
- package/dist/gh/rulesets/actors.d.ts +14 -0
- package/dist/gh/rulesets/actors.js +68 -0
- package/dist/gh/rulesets/api.d.ts +15 -0
- package/dist/gh/rulesets/api.js +99 -0
- package/dist/gh/rulesets/commands.d.ts +3 -0
- package/dist/gh/rulesets/commands.js +203 -0
- package/dist/gh/rulesets/desired.d.ts +112 -0
- package/dist/gh/rulesets/desired.js +247 -0
- package/dist/gh/rulesets/diff.d.ts +65 -0
- package/dist/gh/rulesets/diff.js +134 -0
- package/dist/gh/rulesets/types.d.ts +76 -0
- package/dist/gh/rulesets/types.js +13 -0
- package/dist/gh/settings/api.d.ts +40 -0
- package/dist/gh/settings/api.js +172 -0
- package/dist/gh/settings/commands.d.ts +1 -0
- package/dist/gh/settings/commands.js +245 -0
- package/dist/gh/settings/desired.d.ts +53 -0
- package/dist/gh/settings/desired.js +39 -0
- package/dist/gh/settings/diff.d.ts +57 -0
- package/dist/gh/settings/diff.js +156 -0
- package/dist/gh/settings/types.d.ts +92 -0
- package/dist/gh/settings/types.js +11 -0
- package/dist/gh/settings/workflows.d.ts +41 -0
- package/dist/gh/settings/workflows.js +124 -0
- package/dist/grantRoot.d.ts +42 -0
- package/dist/grantRoot.js +89 -0
- package/dist/help.d.ts +18 -0
- package/dist/help.js +169 -0
- package/dist/images/commands.d.ts +35 -0
- package/dist/images/commands.js +233 -0
- package/dist/images/events.d.ts +36 -0
- package/dist/images/events.js +152 -0
- package/dist/images/graphics.d.ts +70 -0
- package/dist/images/graphics.js +233 -0
- package/dist/images/open-graph-types.d.ts +75 -0
- package/dist/images/open-graph-types.js +1 -0
- package/dist/images/prompts.d.ts +34 -0
- package/dist/images/prompts.js +100 -0
- package/dist/images/render.d.ts +21 -0
- package/dist/images/render.js +57 -0
- package/dist/images/select.d.ts +64 -0
- package/dist/images/select.js +96 -0
- package/dist/instance.d.ts +67 -0
- package/dist/instance.js +129 -0
- package/dist/invocation.d.ts +49 -0
- package/dist/invocation.js +78 -0
- package/dist/launch-ci.d.ts +1 -0
- package/dist/launch-ci.js +86 -0
- package/dist/launch.d.ts +29 -0
- package/dist/launch.js +371 -0
- package/dist/menu.d.ts +31 -0
- package/dist/menu.js +277 -0
- package/dist/newsletter/commands.d.ts +16 -0
- package/dist/newsletter/commands.js +311 -0
- package/dist/newsletter/imap.d.ts +17 -0
- package/dist/newsletter/imap.js +127 -0
- package/dist/newsletter/loopback.d.ts +24 -0
- package/dist/newsletter/loopback.js +100 -0
- package/dist/newsletter/oauth.d.ts +26 -0
- package/dist/newsletter/oauth.js +130 -0
- package/dist/newsletter/smtp.d.ts +23 -0
- package/dist/newsletter/smtp.js +133 -0
- package/dist/oauth/browser.d.ts +1 -0
- package/dist/oauth/browser.js +13 -0
- package/dist/oauth/config.d.ts +24 -0
- package/dist/oauth/config.js +24 -0
- package/dist/oauth/connect-url.d.ts +17 -0
- package/dist/oauth/connect-url.js +23 -0
- package/dist/oauth/db.d.ts +37 -0
- package/dist/oauth/db.js +108 -0
- package/dist/oauth/device.d.ts +93 -0
- package/dist/oauth/device.js +190 -0
- package/dist/oauth/discovery.d.ts +28 -0
- package/dist/oauth/discovery.js +54 -0
- package/dist/oauth/env-file.d.ts +6 -0
- package/dist/oauth/env-file.js +35 -0
- package/dist/oauth/exchange.d.ts +43 -0
- package/dist/oauth/exchange.js +87 -0
- package/dist/oauth/label.d.ts +1 -0
- package/dist/oauth/label.js +25 -0
- package/dist/oauth/loopback.d.ts +52 -0
- package/dist/oauth/loopback.js +162 -0
- package/dist/oauth/pkce.d.ts +6 -0
- package/dist/oauth/pkce.js +34 -0
- package/dist/oauth/target.d.ts +54 -0
- package/dist/oauth/target.js +102 -0
- package/dist/oauth/transport.d.ts +41 -0
- package/dist/oauth/transport.js +32 -0
- package/dist/oauth/wizard.d.ts +33 -0
- package/dist/oauth/wizard.js +571 -0
- package/dist/pick.d.ts +6 -0
- package/dist/pick.js +64 -0
- package/dist/planner/checks.d.ts +26 -0
- package/dist/planner/checks.js +104 -0
- package/dist/planner/commands.d.ts +10 -0
- package/dist/planner/commands.js +367 -0
- package/dist/planner/db.d.ts +15 -0
- package/dist/planner/db.js +33 -0
- package/dist/planner/role.d.ts +33 -0
- package/dist/planner/role.js +68 -0
- package/dist/repo/peers.d.ts +24 -0
- package/dist/repo/peers.js +114 -0
- package/dist/repo/preflight.d.ts +109 -0
- package/dist/repo/preflight.js +266 -0
- package/dist/repo/resolve.d.ts +41 -0
- package/dist/repo/resolve.js +152 -0
- package/dist/repo/root.d.ts +23 -0
- package/dist/repo/root.js +91 -0
- package/dist/repo/source.d.ts +14 -0
- package/dist/repo/source.js +54 -0
- package/dist/repo/tsx-loader.d.ts +6 -0
- package/dist/repo/tsx-loader.js +41 -0
- package/dist/run/pick.d.ts +60 -0
- package/dist/run/pick.js +559 -0
- package/dist/setup.d.ts +1 -0
- package/dist/setup.js +152 -0
- package/dist/stack.d.ts +48 -0
- package/dist/stack.js +290 -0
- package/dist/telemetry.d.ts +34 -0
- package/dist/telemetry.js +93 -0
- package/dist/tier.d.ts +40 -0
- package/dist/tier.js +93 -0
- package/dist/tsconfig.typecheck.tsbuildinfo +1 -0
- package/dist/ui.d.ts +25 -0
- package/dist/ui.js +51 -0
- package/dist/workers.d.ts +5 -0
- package/dist/workers.js +35 -0
- package/dist/workflows/commands.d.ts +44 -0
- package/dist/workflows/commands.js +668 -0
- package/env.ts +158 -0
- package/package.json +80 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets Manager, through the official SDK. No `bws` binary to install.
|
|
3
|
+
*
|
|
4
|
+
* This wrapped the `bws` CLI until 2026-08-19. The CLI was the right call
|
|
5
|
+
* against the raw REST API: Secrets Manager is end-to-end encrypted, the
|
|
6
|
+
* server stores ciphertext, and the client derives the key that opens it from
|
|
7
|
+
* the access token, so `fetch` returns blobs and reimplementing the crypto is
|
|
8
|
+
* not a trade worth making. `@bitwarden/sdk-napi` is that same client-side
|
|
9
|
+
* crypto (the same Rust core the CLI wraps), loaded in-process, which buys two
|
|
10
|
+
* things the CLI could not:
|
|
11
|
+
*
|
|
12
|
+
* * **Nothing to install.** The SDK is a dependency of this package, so
|
|
13
|
+
* `pnpm install` is the whole setup. No "install bws from the releases
|
|
14
|
+
* page" step, and no version somebody's laptop drifted on.
|
|
15
|
+
* * **No credential in argv.** `bws secret create` took the VALUE as a
|
|
16
|
+
* positional argument, visible to `ps` for the length of the call, a
|
|
17
|
+
* documented property of the tool this wrapper could only apologize for.
|
|
18
|
+
* In-process values never touch a process table.
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ Imported LAZILY, at the first real call. The SDK is a native module, and
|
|
21
|
+
* loading it at import time would tax every `cli:no-env` path, the CI guards
|
|
22
|
+
* included, with a `.node` binary none of them use.
|
|
23
|
+
*
|
|
24
|
+
* The one thing the SDK needs that the CLI did not: the ORGANIZATION ID. The
|
|
25
|
+
* CLI derived it from the access token's login response; the SDK's every list
|
|
26
|
+
* and create takes it as an argument, and nothing in its API discovers it. It
|
|
27
|
+
* is a public identifier (a UUID that confers nothing), read from `BWS_ORG_ID`.
|
|
28
|
+
* See the declaration in `packages/devtools/env.ts`.
|
|
29
|
+
*/
|
|
30
|
+
import { mkdir } from "node:fs/promises";
|
|
31
|
+
import { homedir } from "node:os";
|
|
32
|
+
import { dirname, join } from "node:path";
|
|
33
|
+
import { log, select, text } from "@clack/prompts";
|
|
34
|
+
import { NoAccessTokenError, promptForToken, resolveToken } from "./token.js";
|
|
35
|
+
import { withRateLimitRetry } from "./retry.js";
|
|
36
|
+
import { makePacer } from "./pace.js";
|
|
37
|
+
import { saveToDevEnv } from "./store.js";
|
|
38
|
+
import { readTokenFromVault, saveTokenToVault, VAULT_ITEM_NAME, } from "./vault.js";
|
|
39
|
+
import { unwrap } from "../ui.js";
|
|
40
|
+
export class BwsError extends Error {
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* One pacer for the process: every Secrets Manager request funnels through
|
|
44
|
+
* it, spaced under the published per-IP limits so the 429 retry stays a
|
|
45
|
+
* backstop instead of the plan. See `pace.ts` for the numbers and sources.
|
|
46
|
+
*/
|
|
47
|
+
const pace = makePacer();
|
|
48
|
+
let explicitToken;
|
|
49
|
+
let resolved;
|
|
50
|
+
/** Where a just-typed token goes, picked in `offerSave`, used in `save`. */
|
|
51
|
+
let saveDestination = "no";
|
|
52
|
+
/** Records `--access-token`, before any command runs. */
|
|
53
|
+
export function setExplicitAccessToken(token) {
|
|
54
|
+
explicitToken = token;
|
|
55
|
+
resolved = undefined;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The access token, found once per process.
|
|
59
|
+
*
|
|
60
|
+
* Memoized as a **promise**, not a value: resolution can prompt, and a single
|
|
61
|
+
* command makes several Secrets Manager calls. Caching the value alone would
|
|
62
|
+
* still let two concurrent calls open two prompts over each other.
|
|
63
|
+
*
|
|
64
|
+
* Whatever the source, the token reaches the SDK as a function argument in
|
|
65
|
+
* this process, never argv, never a child's environment.
|
|
66
|
+
*/
|
|
67
|
+
export async function accessToken() {
|
|
68
|
+
resolved ??= resolveToken({
|
|
69
|
+
explicit: explicitToken,
|
|
70
|
+
env: process.env.BWS_ACCESS_TOKEN,
|
|
71
|
+
fromVault: readTokenFromVault,
|
|
72
|
+
prompt: promptForToken,
|
|
73
|
+
// The destination is picked when the save is offered, and the pick is
|
|
74
|
+
// carried into `save` by closure: the two callbacks are halves of one
|
|
75
|
+
// exchange. `.env` first: it is this repository's own file, `with-env`
|
|
76
|
+
// already loads it, and `env push` refuses the key by name, so the only
|
|
77
|
+
// machine that can read the saved copy is this one.
|
|
78
|
+
offerSave: async () => {
|
|
79
|
+
saveDestination = unwrap(await select({
|
|
80
|
+
message: "Save it, so this is the last time you paste it?",
|
|
81
|
+
options: [
|
|
82
|
+
{
|
|
83
|
+
value: "env",
|
|
84
|
+
label: "yes — into .env",
|
|
85
|
+
hint: "gitignored, this machine only; push refuses it by name",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
value: "vault",
|
|
89
|
+
label: "yes — into my Bitwarden vault",
|
|
90
|
+
hint: "needs `pnpm devtools bw login`; follows you across machines",
|
|
91
|
+
},
|
|
92
|
+
{ value: "no", label: "no — ask me again next time" },
|
|
93
|
+
],
|
|
94
|
+
initialValue: "env",
|
|
95
|
+
}));
|
|
96
|
+
return saveDestination !== "no";
|
|
97
|
+
},
|
|
98
|
+
save: async (token) => {
|
|
99
|
+
if (saveDestination === "env") {
|
|
100
|
+
await saveToDevEnv("BWS_ACCESS_TOKEN", token);
|
|
101
|
+
log.success("Stored in .env — with-env loads it on every run.");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const saved = await saveTokenToVault(token);
|
|
105
|
+
if (saved) {
|
|
106
|
+
log.success(`Stored as "${VAULT_ITEM_NAME}" in your Bitwarden vault.`);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
log.warn("Could not save it. The command will continue with the token you " +
|
|
110
|
+
"typed; nothing was written to your vault.");
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
onSource: (source) => {
|
|
114
|
+
if (source === "flag") {
|
|
115
|
+
log.warn("--access-token puts a live credential in argv, where `ps` can read " +
|
|
116
|
+
"it for the length of the call, and in your shell history. Prefer " +
|
|
117
|
+
"BWS_ACCESS_TOKEN or the vault.");
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
}).catch((err) => {
|
|
121
|
+
resolved = undefined; // so a later call can ask again
|
|
122
|
+
throw err instanceof NoAccessTokenError ? new BwsError(err.message) : err;
|
|
123
|
+
});
|
|
124
|
+
return resolved;
|
|
125
|
+
}
|
|
126
|
+
let resolvedOrgId;
|
|
127
|
+
/**
|
|
128
|
+
* The organization the machine account belongs to: the environment, else a
|
|
129
|
+
* prompt with an offer to save.
|
|
130
|
+
*
|
|
131
|
+
* Prompted rather than only refused because it is the one Secrets Manager
|
|
132
|
+
* input with nothing secret about it: a public UUID that identifies without
|
|
133
|
+
* authorizing. Where nobody can answer (a pipe), the named refusal below is
|
|
134
|
+
* the answer, because an SDK error about a malformed UUID would send somebody
|
|
135
|
+
* debugging the token, which is the one thing that is fine.
|
|
136
|
+
*/
|
|
137
|
+
function organizationId() {
|
|
138
|
+
resolvedOrgId ??= (async () => {
|
|
139
|
+
const fromEnv = process.env.BWS_ORG_ID;
|
|
140
|
+
if (fromEnv)
|
|
141
|
+
return fromEnv;
|
|
142
|
+
if (!process.stdin.isTTY) {
|
|
143
|
+
throw new BwsError("BWS_ORG_ID is not set. The Secrets Manager SDK addresses everything " +
|
|
144
|
+
"by organization id — a public UUID, shown in the Secrets Manager " +
|
|
145
|
+
"URL (bitwarden.com/#/sm/<org-id>/...) and on the machine-account " +
|
|
146
|
+
"page. Put it in your .env; it identifies, it does not authorize.");
|
|
147
|
+
}
|
|
148
|
+
const typed = unwrap(await text({
|
|
149
|
+
message: "What is the Bitwarden organization id? (the UUID in the Secrets " +
|
|
150
|
+
"Manager URL: bitwarden.com/#/sm/<org-id>/...)",
|
|
151
|
+
validate: (v) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test((v ?? "").trim())
|
|
152
|
+
? undefined
|
|
153
|
+
: "That is not a UUID.",
|
|
154
|
+
})).trim();
|
|
155
|
+
// Saved without a destination question, unlike the token: this is a
|
|
156
|
+
// public identifier with exactly one sensible home, and "ask me every
|
|
157
|
+
// run" is not a state anyone wants for a value that never changes.
|
|
158
|
+
try {
|
|
159
|
+
await saveToDevEnv("BWS_ORG_ID", typed);
|
|
160
|
+
log.success("Stored BWS_ORG_ID in .env.");
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
log.warn(`Could not save it to .env: ${err instanceof Error ? err.message : String(err)}. ` +
|
|
164
|
+
"Continuing with the id you typed.");
|
|
165
|
+
}
|
|
166
|
+
return typed;
|
|
167
|
+
})();
|
|
168
|
+
resolvedOrgId.catch(() => {
|
|
169
|
+
resolvedOrgId = undefined; // so a later call can ask again
|
|
170
|
+
});
|
|
171
|
+
return resolvedOrgId;
|
|
172
|
+
}
|
|
173
|
+
/** The lazily-loaded, logged-in SDK client, one per process. */
|
|
174
|
+
let sdk;
|
|
175
|
+
/**
|
|
176
|
+
* Where the SDK caches its login, so a process is not a fresh authentication.
|
|
177
|
+
*
|
|
178
|
+
* ⚠️ This file is what stops the 429s. Bitwarden's identity endpoint
|
|
179
|
+
* rate-limits repeated access-token logins hard, and "push preflight, audit,
|
|
180
|
+
* push staging, audit, push production, audit" is six devtools processes in
|
|
181
|
+
* two minutes: six logins without this, ONE with it. The `bws` binary kept a
|
|
182
|
+
* state directory for exactly this reason, and the SDK migration dropped it.
|
|
183
|
+
* Per machine account (the token's client-id UUID is in the filename, public
|
|
184
|
+
* by format), under 0700 directories, and outside the repository, because it
|
|
185
|
+
* caches an auth token and must live where nothing syncs or commits it.
|
|
186
|
+
*/
|
|
187
|
+
function stateFileFor(accessToken) {
|
|
188
|
+
const clientId = /^\d+\.([0-9a-f-]{36})\./i.exec(accessToken)?.[1] ?? "default";
|
|
189
|
+
return join(homedir(), ".config", "devdogs-devtools", `bws-state-${clientId}.json`);
|
|
190
|
+
}
|
|
191
|
+
async function client() {
|
|
192
|
+
sdk ??= (async () => {
|
|
193
|
+
const token = await accessToken();
|
|
194
|
+
// Dynamic, so the native module loads only when a command actually
|
|
195
|
+
// talks to Secrets Manager. See the header.
|
|
196
|
+
const { BitwardenClient } = await import("@bitwarden/sdk-napi");
|
|
197
|
+
const instance = new BitwardenClient();
|
|
198
|
+
try {
|
|
199
|
+
const stateFile = stateFileFor(token);
|
|
200
|
+
await mkdir(dirname(stateFile), { recursive: true, mode: 0o700 });
|
|
201
|
+
await withRateLimitRetry(async () => {
|
|
202
|
+
await pace("write");
|
|
203
|
+
return instance.auth().loginAccessToken(token, stateFile);
|
|
204
|
+
}, { doing: "the login" });
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
throw new BwsError(describeSdkFailure(err, "logging in"));
|
|
208
|
+
}
|
|
209
|
+
return instance;
|
|
210
|
+
})();
|
|
211
|
+
sdk.catch(() => {
|
|
212
|
+
sdk = undefined; // so a later call can retry with a fresh token prompt
|
|
213
|
+
});
|
|
214
|
+
return sdk;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Turns an SDK failure into something with a next step in it.
|
|
218
|
+
*
|
|
219
|
+
* The two that actually happen are a rejected token and a token that does not
|
|
220
|
+
* cover the project. The latter surfaces as a bare "not found", because a
|
|
221
|
+
* project you cannot see and a project that does not exist are deliberately
|
|
222
|
+
* the same answer.
|
|
223
|
+
*/
|
|
224
|
+
function describeSdkFailure(err, doing) {
|
|
225
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
226
|
+
if (/\b429\b|too many requests/i.test(message)) {
|
|
227
|
+
return (`${message}\n\n` +
|
|
228
|
+
"Bitwarden rate-limited this even after the automatic retries. Wait a " +
|
|
229
|
+
"minute and re-run — the login is cached in a state file now, so " +
|
|
230
|
+
"re-running does not spend another authentication.");
|
|
231
|
+
}
|
|
232
|
+
if (/404|not.?found/i.test(message)) {
|
|
233
|
+
return (`${message}\n\n` +
|
|
234
|
+
"A 404 here usually means the access token is valid but its machine " +
|
|
235
|
+
"account is not assigned to this project — Secrets Manager reports " +
|
|
236
|
+
"'invisible' and 'absent' identically.");
|
|
237
|
+
}
|
|
238
|
+
if (/401|unauthoriz|invalid|expired/i.test(message)) {
|
|
239
|
+
return `${message}\n\nThe access token was rejected while ${doing}. It may have been revoked or belong to another organization.`;
|
|
240
|
+
}
|
|
241
|
+
return message || `Secrets Manager failed with no message while ${doing}.`;
|
|
242
|
+
}
|
|
243
|
+
function iso(value) {
|
|
244
|
+
if (value instanceof Date)
|
|
245
|
+
return value.toISOString();
|
|
246
|
+
if (typeof value === "string" && value !== "")
|
|
247
|
+
return value;
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
/** Resolves a project name to its id. Names are unique within an org. */
|
|
251
|
+
export async function projectIdFor(name) {
|
|
252
|
+
const c = await client();
|
|
253
|
+
const org = await organizationId();
|
|
254
|
+
let projects;
|
|
255
|
+
try {
|
|
256
|
+
projects = (await withRateLimitRetry(async () => {
|
|
257
|
+
await pace("read");
|
|
258
|
+
return c.projects().list(org);
|
|
259
|
+
}, {
|
|
260
|
+
doing: "listing projects",
|
|
261
|
+
})).data;
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
throw new BwsError(describeSdkFailure(err, "listing projects"));
|
|
265
|
+
}
|
|
266
|
+
const match = projects.find((p) => p.name === name);
|
|
267
|
+
if (!match) {
|
|
268
|
+
const visible = projects.map((p) => p.name).sort();
|
|
269
|
+
throw new BwsError(`No project named "${name}".\n` +
|
|
270
|
+
(visible.length > 0
|
|
271
|
+
? `This token can see: ${visible.join(", ")}`
|
|
272
|
+
: "This token can see no projects at all, which usually means the " +
|
|
273
|
+
"machine account has no project assignments yet."));
|
|
274
|
+
}
|
|
275
|
+
return match.id;
|
|
276
|
+
}
|
|
277
|
+
export async function listSecrets(projectId) {
|
|
278
|
+
const c = await client();
|
|
279
|
+
const org = await organizationId();
|
|
280
|
+
try {
|
|
281
|
+
// The SDK lists IDENTIFIERS org-wide (only what the token may see), then
|
|
282
|
+
// fetches the values in one batch. Filtered to the project HERE, so the
|
|
283
|
+
// callers' contract, "the secrets of this project", survives the transport
|
|
284
|
+
// change byte-for-byte.
|
|
285
|
+
const identifiers = (await withRateLimitRetry(async () => {
|
|
286
|
+
await pace("read");
|
|
287
|
+
return c.secrets().list(org);
|
|
288
|
+
}, {
|
|
289
|
+
doing: "listing secrets",
|
|
290
|
+
})).data;
|
|
291
|
+
if (identifiers.length === 0)
|
|
292
|
+
return [];
|
|
293
|
+
const full = (await withRateLimitRetry(async () => {
|
|
294
|
+
await pace("read");
|
|
295
|
+
return c.secrets().getByIds(identifiers.map((i) => i.id));
|
|
296
|
+
}, { doing: "reading secrets" })).data;
|
|
297
|
+
return full
|
|
298
|
+
.filter((s) => s.projectId === projectId)
|
|
299
|
+
.map((s) => ({
|
|
300
|
+
id: s.id,
|
|
301
|
+
key: s.key,
|
|
302
|
+
value: s.value,
|
|
303
|
+
note: s.note,
|
|
304
|
+
projectId,
|
|
305
|
+
revisionDate: iso(s.revisionDate),
|
|
306
|
+
}));
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
throw new BwsError(describeSdkFailure(err, "listing secrets"));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
export async function createSecret(projectId, key, value, note) {
|
|
313
|
+
const c = await client();
|
|
314
|
+
const org = await organizationId();
|
|
315
|
+
try {
|
|
316
|
+
await withRateLimitRetry(async () => {
|
|
317
|
+
await pace("write");
|
|
318
|
+
return c.secrets().create(org, key, value, note, [projectId]);
|
|
319
|
+
}, { doing: `creating ${key}` });
|
|
320
|
+
}
|
|
321
|
+
catch (err) {
|
|
322
|
+
throw new BwsError(describeSdkFailure(err, `creating ${key}`));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Takes the whole secret rather than an id: the SDK's update is a full
|
|
327
|
+
* replace (organization, key, project list included), and every caller
|
|
328
|
+
* already holds the listed secret it is updating.
|
|
329
|
+
*/
|
|
330
|
+
export async function updateSecret(secret, value, note) {
|
|
331
|
+
const c = await client();
|
|
332
|
+
const org = await organizationId();
|
|
333
|
+
try {
|
|
334
|
+
await withRateLimitRetry(async () => {
|
|
335
|
+
await pace("write");
|
|
336
|
+
return c
|
|
337
|
+
.secrets()
|
|
338
|
+
.update(org, secret.id, secret.key, value, note, [secret.projectId]);
|
|
339
|
+
}, { doing: `updating ${secret.key}` });
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
throw new BwsError(describeSdkFailure(err, `updating ${secret.key}`));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
export async function deleteSecrets(ids) {
|
|
346
|
+
if (ids.length === 0)
|
|
347
|
+
return;
|
|
348
|
+
const c = await client();
|
|
349
|
+
try {
|
|
350
|
+
await withRateLimitRetry(async () => {
|
|
351
|
+
await pace("write");
|
|
352
|
+
return c.secrets().delete(ids);
|
|
353
|
+
}, {
|
|
354
|
+
doing: "deleting secrets",
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
throw new BwsError(describeSdkFailure(err, "deleting secrets"));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
export function byKey(secrets) {
|
|
362
|
+
return new Map(secrets.map((s) => [s.key, s]));
|
|
363
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The targets whose values live in Bitwarden, and what backs each.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ DERIVED, NOT DECLARED. The vault targets and each one's project name come
|
|
5
|
+
* from the one target table in `@devdogsuga/env`
|
|
6
|
+
* (Backstage's `packages/env/src/targets.ts`); only the prose summaries are added here.
|
|
7
|
+
* This file used to declare its own `preflight | staging | production` enum
|
|
8
|
+
* beside that package's `development | staging | production` one, both behind
|
|
9
|
+
* a flag spelled `--env`, and the two agreeing on the words `staging` and
|
|
10
|
+
* `production` is what let `push --env staging` read the development `.env`
|
|
11
|
+
* while `init --env staging` wrote `.env.staging`. See the table's own comment
|
|
12
|
+
* for the full account.
|
|
13
|
+
*
|
|
14
|
+
* **Bitwarden is the source of truth, and CI never reads it.** Values are
|
|
15
|
+
* curated in a local env file and sent onward by `pnpm devtools env push`,
|
|
16
|
+
* which writes Bitwarden AND the GitHub environment secrets in one go. Deploy
|
|
17
|
+
* jobs then read `${{ secrets.* }}` like any other workflow.
|
|
18
|
+
*
|
|
19
|
+
* That decision sets the whole budget. Nothing machine-shaped authenticates to
|
|
20
|
+
* Secrets Manager, so there are **no CI machine accounts**, just one `admin`
|
|
21
|
+
* account, held by a person, read/write on all three projects:
|
|
22
|
+
*
|
|
23
|
+
* | Used | Free plan |
|
|
24
|
+
* |------|-----------|
|
|
25
|
+
* | 3 projects: preflight, staging, production | 3 |
|
|
26
|
+
* | 1 machine account, `admin` | 3 |
|
|
27
|
+
*
|
|
28
|
+
* Two machine accounts spare, where the previous shape had none and could not
|
|
29
|
+
* afford a project for `preflight`.
|
|
30
|
+
*
|
|
31
|
+
* There are three projects and FOUR targets: `development` has none. `.env` is
|
|
32
|
+
* one contributor's own file, there is no shared development credential set to
|
|
33
|
+
* sync it against, and `pull`/`push`/`audit` refuse that target by name rather
|
|
34
|
+
* than falling through to a default project.
|
|
35
|
+
*
|
|
36
|
+
* The sync costs a second copy that cannot be read back: GitHub secrets are
|
|
37
|
+
* write-only, so `env audit` compares names and `updatedAt` against each
|
|
38
|
+
* secret's `revisionDate` here. That catches the realistic failure, a rotation
|
|
39
|
+
* pushed to Bitwarden and never propagated, without ever comparing values.
|
|
40
|
+
*
|
|
41
|
+
* ⚠️ These projects hold the PUBLIC per-environment values too, not only the
|
|
42
|
+
* secrets: `PROJECT_REF`, `BASE_URL`, `PUBLISHABLE_KEY` and the rest go on to
|
|
43
|
+
* GitHub as *variables* rather than secrets. "Bitwarden is the source of truth"
|
|
44
|
+
* has to be true of a whole target for `pull` to rebuild an env file an app can
|
|
45
|
+
* boot from; while it was true of the secret half only, 27 values existed
|
|
46
|
+
* nowhere but somebody's laptop. Variables ARE readable back, so for those keys
|
|
47
|
+
* the audit compares values and the paragraph above does not apply.
|
|
48
|
+
*
|
|
49
|
+
* What it buys: no `bws` binary and no Bitwarden network call in the deploy
|
|
50
|
+
* path, and `${{ secrets.* }}` is masked in workflow logs automatically, which
|
|
51
|
+
* a value pulled at run time is not unless somebody remembers `::add-mask::`.
|
|
52
|
+
*/
|
|
53
|
+
import type { EnvTarget, VaultTarget } from "@devdogsuga/env";
|
|
54
|
+
export type { VaultTarget };
|
|
55
|
+
export declare const isVaultTarget: (target: EnvTarget) => target is VaultTarget;
|
|
56
|
+
export interface EnvironmentSpec {
|
|
57
|
+
/** BWS project name. Resolved to a UUID at run time, never committed. */
|
|
58
|
+
project: string;
|
|
59
|
+
/** Extra confirmation before writing. */
|
|
60
|
+
guarded: boolean;
|
|
61
|
+
summary: string;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Was a module-level constant, computed eagerly at import time. Now lazy:
|
|
65
|
+
* `VAULT_TARGETS`/`projectFor`/`isGuarded` come from the repo-resolved
|
|
66
|
+
* `@devdogsuga/env` (see `repo/peers.ts`), which is not available until
|
|
67
|
+
* SOMETHING has awaited `loadEnv()` — every real caller already does, via
|
|
68
|
+
* `env/discovery.ts`'s `loadRegistry()` or `pick.ts`'s own `loadEnv()` call.
|
|
69
|
+
* Caching keeps repeat reads (env commands call this per target) cheap.
|
|
70
|
+
*/
|
|
71
|
+
export declare function environmentSpecs(): Record<VaultTarget, EnvironmentSpec>;
|
|
72
|
+
/**
|
|
73
|
+
* `--target development` reached `pull`, `push` or `audit`.
|
|
74
|
+
*
|
|
75
|
+
* Its own error rather than a lookup that returns `undefined`, because the old
|
|
76
|
+
* shape of this mistake was silence: `development` was not in the vault enum,
|
|
77
|
+
* so nothing named it and nothing said why. It is a real target with a real
|
|
78
|
+
* file; what it lacks is a shared credential set to sync against.
|
|
79
|
+
*/
|
|
80
|
+
export declare class NoVaultProjectError extends Error {
|
|
81
|
+
readonly target: EnvTarget;
|
|
82
|
+
constructor(target: EnvTarget);
|
|
83
|
+
}
|
|
84
|
+
/** Said wherever the refusal is reported, so the advice cannot drift. */
|
|
85
|
+
export declare function noVaultProjectHints(): string[];
|
|
86
|
+
/**
|
|
87
|
+
* Refuses the one target that has no project, and narrows the rest.
|
|
88
|
+
*
|
|
89
|
+
* Called at the top of every command that talks to a vault, so the refusal is a
|
|
90
|
+
* property of the commands rather than of the argument parser alone: a new
|
|
91
|
+
* subcommand that forgets to validate its flag still cannot fall through to a
|
|
92
|
+
* default project. It narrows as well as throws, so everything after the call
|
|
93
|
+
* is typed as a target that HAS a project rather than merely believed to be
|
|
94
|
+
* one.
|
|
95
|
+
*/
|
|
96
|
+
export declare function assertVaultTarget(target: EnvTarget): asserts target is VaultTarget;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { getEnvSync } from "../repo/peers.js";
|
|
2
|
+
export const isVaultTarget = (target) => getEnvSync().isVaultTarget(target);
|
|
3
|
+
const SUMMARIES = {
|
|
4
|
+
// ⚠️ "Read-only by construction" is now enforced rather than described:
|
|
5
|
+
// `preflight` is the one row with `deployEnv: false`, and `ignoredFor()`
|
|
6
|
+
// reads that as "carries only the keys declared `narrowed`". Until it did,
|
|
7
|
+
// `env push --target preflight` uploaded all 45 routable keys, including
|
|
8
|
+
// SUPABASE_JWT_SIGNING_KEY, SECRET_KEY and GH_APP_PRIVATE_KEY, into a project
|
|
9
|
+
// whose GitHub environment `main` can reach.
|
|
10
|
+
//
|
|
11
|
+
// `DB_URL` is the one key `narrowed: true` opts in today, so this project
|
|
12
|
+
// carries nothing else.
|
|
13
|
+
preflight: "Credentials for the dry runs that precede a promotion to production. " +
|
|
14
|
+
"Read-only by construction: a Postgres role that can see only the " +
|
|
15
|
+
"migrations table and nothing else.",
|
|
16
|
+
staging: "Everything the two Next apps consume, pointed at staging.",
|
|
17
|
+
production: "The live values. Shared with the production-apply environment, which " +
|
|
18
|
+
"is the same project behind required reviewers.",
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Project *names*, not ids. See `TargetSpec.project` for why.
|
|
22
|
+
*
|
|
23
|
+
* Assembled from the target table rather than typed out again: a second copy of
|
|
24
|
+
* "staging means staging" is a copy that can disagree, and the disagreement
|
|
25
|
+
* would be silent in exactly the direction that matters.
|
|
26
|
+
*/
|
|
27
|
+
let cachedSpecs;
|
|
28
|
+
/**
|
|
29
|
+
* Was a module-level constant, computed eagerly at import time. Now lazy:
|
|
30
|
+
* `VAULT_TARGETS`/`projectFor`/`isGuarded` come from the repo-resolved
|
|
31
|
+
* `@devdogsuga/env` (see `repo/peers.ts`), which is not available until
|
|
32
|
+
* SOMETHING has awaited `loadEnv()` — every real caller already does, via
|
|
33
|
+
* `env/discovery.ts`'s `loadRegistry()` or `pick.ts`'s own `loadEnv()` call.
|
|
34
|
+
* Caching keeps repeat reads (env commands call this per target) cheap.
|
|
35
|
+
*/
|
|
36
|
+
export function environmentSpecs() {
|
|
37
|
+
if (cachedSpecs)
|
|
38
|
+
return cachedSpecs;
|
|
39
|
+
const env = getEnvSync();
|
|
40
|
+
cachedSpecs = Object.fromEntries(env.VAULT_TARGETS.map((target) => [
|
|
41
|
+
target,
|
|
42
|
+
{
|
|
43
|
+
// Non-null by construction: `VAULT_TARGETS` is the targets whose
|
|
44
|
+
// `project` is not null.
|
|
45
|
+
project: env.projectFor(target),
|
|
46
|
+
guarded: env.isGuarded(target),
|
|
47
|
+
summary: SUMMARIES[target],
|
|
48
|
+
},
|
|
49
|
+
]));
|
|
50
|
+
return cachedSpecs;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* `--target development` reached `pull`, `push` or `audit`.
|
|
54
|
+
*
|
|
55
|
+
* Its own error rather than a lookup that returns `undefined`, because the old
|
|
56
|
+
* shape of this mistake was silence: `development` was not in the vault enum,
|
|
57
|
+
* so nothing named it and nothing said why. It is a real target with a real
|
|
58
|
+
* file; what it lacks is a shared credential set to sync against.
|
|
59
|
+
*/
|
|
60
|
+
export class NoVaultProjectError extends Error {
|
|
61
|
+
target;
|
|
62
|
+
constructor(target) {
|
|
63
|
+
super(`--target ${target} has no Bitwarden project, so there is nothing to ` +
|
|
64
|
+
`pull from, push to, or audit against. ${getEnvSync().fileFor(target)} is your own ` +
|
|
65
|
+
`file — nobody else's copy is meant to match it.`);
|
|
66
|
+
this.target = target;
|
|
67
|
+
this.name = "NoVaultProjectError";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Said wherever the refusal is reported, so the advice cannot drift. */
|
|
71
|
+
export function noVaultProjectHints() {
|
|
72
|
+
return [
|
|
73
|
+
`Pass --target ${getEnvSync().VAULT_TARGETS.join(" | ")} instead.`,
|
|
74
|
+
"`pnpm devtools env reset` blanks the values in a local file, keeping each",
|
|
75
|
+
"recoverable as a comment. `pnpm devtools setup` creates a fresh .env.",
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Refuses the one target that has no project, and narrows the rest.
|
|
80
|
+
*
|
|
81
|
+
* Called at the top of every command that talks to a vault, so the refusal is a
|
|
82
|
+
* property of the commands rather than of the argument parser alone: a new
|
|
83
|
+
* subcommand that forgets to validate its flag still cannot fall through to a
|
|
84
|
+
* default project. It narrows as well as throws, so everything after the call
|
|
85
|
+
* is typed as a target that HAS a project rather than merely believed to be
|
|
86
|
+
* one.
|
|
87
|
+
*/
|
|
88
|
+
export function assertVaultTarget(target) {
|
|
89
|
+
if (!getEnvSync().isVaultTarget(target))
|
|
90
|
+
throw new NoVaultProjectError(target);
|
|
91
|
+
}
|
|
92
|
+
// ── Where the key sets went ──────────────────────────────────────────────────
|
|
93
|
+
//
|
|
94
|
+
// The hand-maintained arrays that used to live here (NEVER_STORE_KEYS,
|
|
95
|
+
// NEVER_SECRET_KEYS, APPLY_ONLY_KEYS) are now DERIVED from the declarations in
|
|
96
|
+
// each app's env manifest: `neverStoreKeys()`, `neverSecretKeys()`,
|
|
97
|
+
// `applyOnlyKeys()` and `storableKeys()` from `@devdogsuga/env`, populated by
|
|
98
|
+
// devtools' `env/discovery.ts` (`loadRegistry()`). The arrays were correct;
|
|
99
|
+
// what they could not do is STAY correct, because nothing connected them to the
|
|
100
|
+
// schemas they described. A key added to an `env.ts` and not here was pushed to
|
|
101
|
+
// Bitwarden as a secret by omission.
|
|
102
|
+
//
|
|
103
|
+
// The reasoning moved with the keys, onto the declarations themselves:
|
|
104
|
+
//
|
|
105
|
+
// * why `BWS_ACCESS_TOKEN` is refused storage anywhere (a key locked inside
|
|
106
|
+
// the box it opens): its `define()` doc in `packages/devtools/env.ts`;
|
|
107
|
+
// * why apply-tier is a GITHUB routing rule and not a Bitwarden one (the
|
|
108
|
+
// production BWS project deliberately holds the apply credential): the
|
|
109
|
+
// `EnvTier` doc in Backstage's `packages/env/src/meta.ts`;
|
|
110
|
+
// * why the apply credential stays out of staging/preflight: `ignoredFor()`
|
|
111
|
+
// in `../env/selection.ts`.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spacing Secrets Manager calls so the rate limit never fires.
|
|
3
|
+
*
|
|
4
|
+
* Bitwarden's published server config (enforced on cloud, per IP, per
|
|
5
|
+
* endpoint) allows 60 POSTs and 200 GETs per minute and answers 429 beyond.
|
|
6
|
+
* Community reports show burst enforcement tighter than that per-minute
|
|
7
|
+
* arithmetic. The identity endpoint's login limit is undocumented and tighter
|
|
8
|
+
* still; the state-file login cache is what handles that one. Researched
|
|
9
|
+
* 2026-08-20: https://bitwarden-server.mintlify.app/services/api
|
|
10
|
+
*
|
|
11
|
+
* So writes (create, update, delete, login) start no closer than 1.1s apart,
|
|
12
|
+
* 60/min with margin, and reads (list, getByIds) no closer than 350ms. A full
|
|
13
|
+
* first push (~48 creates) takes a predictable ~55 seconds instead of a sprint
|
|
14
|
+
* into a 429; later pushes touch few keys and barely notice. The 429 retry is
|
|
15
|
+
* the backstop for whatever the undocumented parts still throw.
|
|
16
|
+
*
|
|
17
|
+
* One shared gate rather than one per class, and the write interval applies to
|
|
18
|
+
* the gap after a write whatever follows it: the limiter cares about the
|
|
19
|
+
* request stream from this IP, not our taxonomy of it.
|
|
20
|
+
*/
|
|
21
|
+
export type CallKind = "read" | "write";
|
|
22
|
+
export declare const WRITE_GAP_MS = 1100;
|
|
23
|
+
export declare const READ_GAP_MS = 350;
|
|
24
|
+
export interface Pacer {
|
|
25
|
+
(kind: CallKind): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export declare function makePacer(now?: () => number, sleep?: (ms: number) => Promise<void>): Pacer;
|
package/dist/bws/pace.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spacing Secrets Manager calls so the rate limit never fires.
|
|
3
|
+
*
|
|
4
|
+
* Bitwarden's published server config (enforced on cloud, per IP, per
|
|
5
|
+
* endpoint) allows 60 POSTs and 200 GETs per minute and answers 429 beyond.
|
|
6
|
+
* Community reports show burst enforcement tighter than that per-minute
|
|
7
|
+
* arithmetic. The identity endpoint's login limit is undocumented and tighter
|
|
8
|
+
* still; the state-file login cache is what handles that one. Researched
|
|
9
|
+
* 2026-08-20: https://bitwarden-server.mintlify.app/services/api
|
|
10
|
+
*
|
|
11
|
+
* So writes (create, update, delete, login) start no closer than 1.1s apart,
|
|
12
|
+
* 60/min with margin, and reads (list, getByIds) no closer than 350ms. A full
|
|
13
|
+
* first push (~48 creates) takes a predictable ~55 seconds instead of a sprint
|
|
14
|
+
* into a 429; later pushes touch few keys and barely notice. The 429 retry is
|
|
15
|
+
* the backstop for whatever the undocumented parts still throw.
|
|
16
|
+
*
|
|
17
|
+
* One shared gate rather than one per class, and the write interval applies to
|
|
18
|
+
* the gap after a write whatever follows it: the limiter cares about the
|
|
19
|
+
* request stream from this IP, not our taxonomy of it.
|
|
20
|
+
*/
|
|
21
|
+
export const WRITE_GAP_MS = 1_100;
|
|
22
|
+
export const READ_GAP_MS = 350;
|
|
23
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
24
|
+
export function makePacer(now = Date.now, sleep = wait) {
|
|
25
|
+
let notBefore = 0;
|
|
26
|
+
let chain = Promise.resolve();
|
|
27
|
+
return (kind) => {
|
|
28
|
+
const gap = kind === "write" ? WRITE_GAP_MS : READ_GAP_MS;
|
|
29
|
+
const turn = chain.then(async () => {
|
|
30
|
+
const pause = notBefore - now();
|
|
31
|
+
if (pause > 0)
|
|
32
|
+
await sleep(pause);
|
|
33
|
+
// The NEXT call may not start until this one's gap has passed.
|
|
34
|
+
// Reserved before the call runs, so concurrent callers queue instead
|
|
35
|
+
// of all measuring from the same stale timestamp.
|
|
36
|
+
notBefore = now() + gap;
|
|
37
|
+
});
|
|
38
|
+
// The chain never rejects: a pacer that fails closed would wedge every
|
|
39
|
+
// later call behind a rejection that had nothing to do with them.
|
|
40
|
+
chain = turn.catch(() => { });
|
|
41
|
+
return turn;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare function isRateLimited(err: unknown): boolean;
|
|
2
|
+
export interface RetryOptions {
|
|
3
|
+
/** Attempts AFTER the first. */
|
|
4
|
+
retries?: number;
|
|
5
|
+
/** First wait; doubles per retry (5s, 10s, 20s by default). */
|
|
6
|
+
baseMs?: number;
|
|
7
|
+
/** Injectable for tests. */
|
|
8
|
+
sleep?: (ms: number) => Promise<void>;
|
|
9
|
+
/** What to call the operation in the wait message. */
|
|
10
|
+
doing: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function withRateLimitRetry<T>(op: () => Promise<T>, { retries, baseMs, sleep, doing }: RetryOptions): Promise<T>;
|