@mnemom/mnemom 0.17.37 → 0.17.38-next.1
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/dist/commands/agent-doctor.d.ts +2 -2
- package/dist/commands/agent-doctor.js +4 -4
- package/dist/commands/agent-run.d.ts +11 -0
- package/dist/commands/agent-run.js +37 -0
- package/dist/commands/agent-setup.d.ts +11 -9
- package/dist/commands/agent-setup.js +24 -19
- package/dist/commands/agent-share-render.d.ts +5 -5
- package/dist/commands/agent-share-render.js +9 -7
- package/dist/commands/agent-share-test-issuer.d.ts +1 -1
- package/dist/commands/agent-share-test-issuer.js +1 -1
- package/dist/commands/agent.d.ts +8 -1
- package/dist/commands/agent.js +14 -3
- package/dist/commands/card.js +1 -1
- package/dist/commands/onboard.js +5 -4
- package/dist/commands/scan.d.ts +35 -8
- package/dist/commands/scan.js +134 -45
- package/dist/commands/verify-card.d.ts +25 -1
- package/dist/commands/verify-card.js +29 -2
- package/dist/commands/wrap.js +4 -3
- package/dist/index.js +8 -6
- package/dist/lib/commit-report.d.ts +7 -13
- package/dist/lib/commit-report.js +12 -5
- package/dist/lib/conduct-receipt.d.ts +8 -3
- package/dist/lib/conduct-receipt.js +29 -7
- package/dist/lib/experimental.d.ts +1 -1
- package/dist/lib/experimental.js +1 -12
- package/dist/lib/runner/executor-client.d.ts +5 -0
- package/dist/lib/runner/executor-client.js +8 -0
- package/dist/lib/runner/job.d.ts +37 -0
- package/dist/lib/runner/job.js +74 -1
- package/dist/lib/runner/phases.d.ts +7 -2
- package/dist/lib/runner/phases.js +7 -2
- package/dist/lib/runner/proxy.d.ts +26 -2
- package/dist/lib/runner/proxy.js +33 -1
- package/dist/lib/runner/record-commit.d.ts +16 -0
- package/dist/lib/runner/record-commit.js +7 -0
- package/dist/lib/scan/report-email.d.ts +19 -2
- package/dist/lib/scan/report-email.js +78 -22
- package/dist/lib/scan/scan.d.ts +2 -0
- package/dist/lib/scan/scan.js +2 -1
- package/dist/lib/scan/share.d.ts +8 -6
- package/dist/lib/scan/share.js +28 -11
- package/dist/lib/scan/spend/anthropic-usage.d.ts +20 -1
- package/dist/lib/scan/spend/anthropic-usage.js +32 -7
- package/dist/lib/scan/spend/anthropic-wif.d.ts +42 -0
- package/dist/lib/scan/spend/anthropic-wif.js +153 -0
- package/dist/lib/starter-integrity.d.ts +20 -0
- package/dist/lib/starter-integrity.js +28 -0
- package/package.json +2 -5
- package/node_modules/@mnemom/aip-verifier/LICENSE +0 -190
- package/node_modules/@mnemom/aip-verifier/dist/claim-lint-CVITIRcU.d.cts +0 -478
- package/node_modules/@mnemom/aip-verifier/dist/claim-lint-CVITIRcU.d.ts +0 -478
- package/node_modules/@mnemom/aip-verifier/dist/index.cjs +0 -971
- package/node_modules/@mnemom/aip-verifier/dist/index.cjs.map +0 -1
- package/node_modules/@mnemom/aip-verifier/dist/index.d.cts +0 -330
- package/node_modules/@mnemom/aip-verifier/dist/index.d.ts +0 -330
- package/node_modules/@mnemom/aip-verifier/dist/index.js +0 -933
- package/node_modules/@mnemom/aip-verifier/dist/index.js.map +0 -1
- package/node_modules/@mnemom/aip-verifier/dist/render-lines.cjs +0 -879
- package/node_modules/@mnemom/aip-verifier/dist/render-lines.cjs.map +0 -1
- package/node_modules/@mnemom/aip-verifier/dist/render-lines.d.cts +0 -96
- package/node_modules/@mnemom/aip-verifier/dist/render-lines.d.ts +0 -96
- package/node_modules/@mnemom/aip-verifier/dist/render-lines.js +0 -875
- package/node_modules/@mnemom/aip-verifier/dist/render-lines.js.map +0 -1
- package/node_modules/@mnemom/aip-verifier/package.json +0 -86
- package/node_modules/@noble/ed25519/LICENSE +0 -21
- package/node_modules/@noble/ed25519/README.md +0 -371
- package/node_modules/@noble/ed25519/index.d.ts +0 -391
- package/node_modules/@noble/ed25519/index.js +0 -933
- package/node_modules/@noble/ed25519/index.ts +0 -1134
- package/node_modules/@noble/ed25519/package.json +0 -54
- package/node_modules/@noble/hashes/LICENSE +0 -21
- package/node_modules/@noble/hashes/README.md +0 -478
- package/node_modules/@noble/hashes/_blake.d.ts +0 -20
- package/node_modules/@noble/hashes/_blake.js +0 -59
- package/node_modules/@noble/hashes/_md.d.ts +0 -95
- package/node_modules/@noble/hashes/_md.js +0 -209
- package/node_modules/@noble/hashes/_u64.d.ts +0 -36
- package/node_modules/@noble/hashes/_u64.js +0 -77
- package/node_modules/@noble/hashes/argon2.d.ts +0 -148
- package/node_modules/@noble/hashes/argon2.js +0 -577
- package/node_modules/@noble/hashes/blake1.d.ts +0 -162
- package/node_modules/@noble/hashes/blake1.js +0 -630
- package/node_modules/@noble/hashes/blake2.d.ts +0 -203
- package/node_modules/@noble/hashes/blake2.js +0 -573
- package/node_modules/@noble/hashes/blake3.d.ts +0 -68
- package/node_modules/@noble/hashes/blake3.js +0 -310
- package/node_modules/@noble/hashes/eskdf.d.ts +0 -91
- package/node_modules/@noble/hashes/eskdf.js +0 -225
- package/node_modules/@noble/hashes/hkdf.d.ts +0 -70
- package/node_modules/@noble/hashes/hkdf.js +0 -152
- package/node_modules/@noble/hashes/hmac.d.ts +0 -50
- package/node_modules/@noble/hashes/hmac.js +0 -93
- package/node_modules/@noble/hashes/index.d.ts +0 -1
- package/node_modules/@noble/hashes/index.js +0 -34
- package/node_modules/@noble/hashes/legacy.d.ts +0 -107
- package/node_modules/@noble/hashes/legacy.js +0 -348
- package/node_modules/@noble/hashes/package.json +0 -91
- package/node_modules/@noble/hashes/pbkdf2.d.ts +0 -66
- package/node_modules/@noble/hashes/pbkdf2.js +0 -176
- package/node_modules/@noble/hashes/scrypt.d.ts +0 -98
- package/node_modules/@noble/hashes/scrypt.js +0 -304
- package/node_modules/@noble/hashes/sha2.d.ts +0 -176
- package/node_modules/@noble/hashes/sha2.js +0 -446
- package/node_modules/@noble/hashes/sha3-addons.d.ts +0 -533
- package/node_modules/@noble/hashes/sha3-addons.js +0 -973
- package/node_modules/@noble/hashes/sha3.d.ts +0 -208
- package/node_modules/@noble/hashes/sha3.js +0 -475
- package/node_modules/@noble/hashes/src/_blake.ts +0 -67
- package/node_modules/@noble/hashes/src/_md.ts +0 -227
- package/node_modules/@noble/hashes/src/_u64.ts +0 -111
- package/node_modules/@noble/hashes/src/argon2.ts +0 -693
- package/node_modules/@noble/hashes/src/blake1.ts +0 -677
- package/node_modules/@noble/hashes/src/blake2.ts +0 -648
- package/node_modules/@noble/hashes/src/blake3.ts +0 -344
- package/node_modules/@noble/hashes/src/eskdf.ts +0 -265
- package/node_modules/@noble/hashes/src/hkdf.ts +0 -161
- package/node_modules/@noble/hashes/src/hmac.ts +0 -130
- package/node_modules/@noble/hashes/src/index.ts +0 -33
- package/node_modules/@noble/hashes/src/legacy.ts +0 -361
- package/node_modules/@noble/hashes/src/pbkdf2.ts +0 -219
- package/node_modules/@noble/hashes/src/scrypt.ts +0 -375
- package/node_modules/@noble/hashes/src/sha2.ts +0 -486
- package/node_modules/@noble/hashes/src/sha3-addons.ts +0 -1157
- package/node_modules/@noble/hashes/src/sha3.ts +0 -525
- package/node_modules/@noble/hashes/src/utils.ts +0 -978
- package/node_modules/@noble/hashes/src/webcrypto.ts +0 -283
- package/node_modules/@noble/hashes/utils.d.ts +0 -560
- package/node_modules/@noble/hashes/utils.js +0 -687
- package/node_modules/@noble/hashes/webcrypto.d.ts +0 -121
- package/node_modules/@noble/hashes/webcrypto.js +0 -228
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The report email: after every successful scan the
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* The report email: after every successful scan the full report, with the counts for each repo, is
|
|
3
|
+
* emailed to the person who ran it, every Org Admin of the org and anyone the runner added (MNE-9634),
|
|
4
|
+
* with no opt-out and at every share level. One call to `POST /v1/orgs/{org_id}/scan/report-email`.
|
|
5
|
+
* The server resolves the runner's address from the signed-in identity and the Org Admins from the org,
|
|
6
|
+
* so the request carries neither; `emails` holds only the extra addresses the runner added.
|
|
6
7
|
*
|
|
7
|
-
* The body is exactly the route's contract and nothing else: `report` (org-scan-report/1: team totals
|
|
8
|
-
* and counts
|
|
9
|
-
*
|
|
8
|
+
* The body is exactly the route's contract and nothing else: `report` (org-scan-report/1: team totals,
|
|
9
|
+
* counts, and the per-repo counts `by_repo`), `share_level`, `coverage.complete` and the optional
|
|
10
|
+
* `emails`. No author, title, branch or body text, no credential.
|
|
10
11
|
*
|
|
11
12
|
* Every failure has a fixed name and fixed text. The server's body, the thrown value and the
|
|
12
13
|
* credential never reach the terminal.
|
|
@@ -40,6 +41,7 @@ export function buildEmailReport(result, o) {
|
|
|
40
41
|
// Only a scan that read every merged PR in the period gives an average that means anything.
|
|
41
42
|
const exhaustive = coverage.complete && coverage.prsSkippedLarge === 0 && coverage.prsUnreadable === 0;
|
|
42
43
|
const cost = buildCostPerPr(result.prSizes, o.period ?? { start: coverage.windowStart, end: coverage.windowEnd }, o.costSpend ?? null, exhaustive);
|
|
44
|
+
const byRepo = repoRows(result);
|
|
43
45
|
return {
|
|
44
46
|
schema_version: REPORT_SCHEMA_VERSION,
|
|
45
47
|
org_id: o.orgId,
|
|
@@ -72,14 +74,38 @@ export function buildEmailReport(result, o) {
|
|
|
72
74
|
},
|
|
73
75
|
estimate: { name: ESTIMATE_NAME, value, unit, method_url: METHOD_URL },
|
|
74
76
|
...cost,
|
|
77
|
+
...(byRepo.length > 0 ? { by_repo: byRepo } : {}),
|
|
75
78
|
};
|
|
76
79
|
}
|
|
77
|
-
|
|
80
|
+
const MAX_REPOS = 500;
|
|
81
|
+
const REPO_NAME = /^[A-Za-z0-9._-]{1,100}\/[A-Za-z0-9._-]{1,100}$/;
|
|
82
|
+
/** The per-repo rows for the report: valid names only, each once (any case), busiest first, at most 500. */
|
|
83
|
+
function repoRows(result) {
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
return [...result.byRepo]
|
|
86
|
+
.sort((a, b) => b.prs - a.prs || a.repo.localeCompare(b.repo))
|
|
87
|
+
.filter((r) => {
|
|
88
|
+
const k = r.repo.toLowerCase();
|
|
89
|
+
if (!REPO_NAME.test(r.repo) || seen.has(k))
|
|
90
|
+
return false;
|
|
91
|
+
seen.add(k);
|
|
92
|
+
return true;
|
|
93
|
+
})
|
|
94
|
+
.slice(0, MAX_REPOS)
|
|
95
|
+
.map((r) => ({
|
|
96
|
+
repo: r.repo,
|
|
97
|
+
pr_count: r.prs,
|
|
98
|
+
revert_count: r.reverts,
|
|
99
|
+
automatable_count: r.ciGreen,
|
|
100
|
+
examined_count: r.examined,
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
export function buildEmailBody(report, level, complete, extraEmails = []) {
|
|
78
104
|
return {
|
|
79
105
|
report,
|
|
80
106
|
share_level: level,
|
|
81
107
|
coverage: { complete },
|
|
82
|
-
...(
|
|
108
|
+
...(extraEmails.length > 0 ? { emails: extraEmails } : {}),
|
|
83
109
|
};
|
|
84
110
|
}
|
|
85
111
|
const FAILURE_TEXT = {
|
|
@@ -89,7 +115,10 @@ const FAILURE_TEXT = {
|
|
|
89
115
|
not_a_member: "your account is not a member of the org passed to --org.",
|
|
90
116
|
report_rejected: "Mnemom rejected the report as malformed.",
|
|
91
117
|
no_recipients: "the server accepted the report but reported zero recipients.",
|
|
92
|
-
runner_not_emailed: "the report was not delivered to you (the person who ran the scan); it may have gone only to the
|
|
118
|
+
runner_not_emailed: "the report was not delivered to you (the person who ran the scan); it may have gone only to the others. Re-run the scan, and check that your account has a valid email address.",
|
|
119
|
+
emails_rejected: "an address you added was not accepted. Add up to 10 valid email addresses.",
|
|
120
|
+
email_domain_not_allowed: "an address you added is not on your organization's email domain. Add addresses on your own domain or mnemom.ai.",
|
|
121
|
+
admins_not_found: "Mnemom could not look up your org admins, so no email was sent. Try again in a moment.",
|
|
93
122
|
network_error: "Mnemom could not be reached.",
|
|
94
123
|
unexpected_response: "Mnemom answered in an unexpected way.",
|
|
95
124
|
};
|
|
@@ -118,11 +147,27 @@ function classifyStatus(status) {
|
|
|
118
147
|
return "report_rejected";
|
|
119
148
|
return "unexpected_response";
|
|
120
149
|
}
|
|
121
|
-
/**
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
150
|
+
/**
|
|
151
|
+
* The body an API that predates this contract understands: no cost figures, no per-repo rows, and the
|
|
152
|
+
* share level under its old name (`analytics` was `summary`; the meaning is the same, never upgraded).
|
|
153
|
+
* The same object when nothing needs changing.
|
|
154
|
+
*/
|
|
155
|
+
function legacyBody(body) {
|
|
156
|
+
const { cost_per_merged_pr: _a, cost_by_pr_size: _b, by_repo: _c, ...rest } = body.report;
|
|
157
|
+
const changed = "cost_per_merged_pr" in body.report ||
|
|
158
|
+
"cost_by_pr_size" in body.report ||
|
|
159
|
+
"by_repo" in body.report ||
|
|
160
|
+
body.share_level === "analytics" ||
|
|
161
|
+
(body.emails?.length ?? 0) > 0;
|
|
162
|
+
const { emails, ...top } = body;
|
|
163
|
+
const extra = emails?.[0];
|
|
164
|
+
return changed
|
|
165
|
+
? {
|
|
166
|
+
...top,
|
|
167
|
+
...(extra ? { cto_email: extra } : {}),
|
|
168
|
+
report: rest,
|
|
169
|
+
share_level: body.share_level === "analytics" ? "summary" : body.share_level,
|
|
170
|
+
}
|
|
126
171
|
: body;
|
|
127
172
|
}
|
|
128
173
|
/** Never throws. The thrown value, the response body and the credential are never surfaced. */
|
|
@@ -135,15 +180,23 @@ export async function sendReportEmail(orgId, body, deps = {}) {
|
|
|
135
180
|
catch (e) {
|
|
136
181
|
if (e instanceof MnemomApiError && e.effectiveStatus === 404)
|
|
137
182
|
return { status: "unavailable" };
|
|
138
|
-
// An API that predates the cost figures
|
|
139
|
-
// go out, so retry once
|
|
183
|
+
// An API that predates the cost figures, the per-repo rows or the analytics name rejects them with a 400. The email must still
|
|
184
|
+
// go out, so retry once in the old shape; any other failure is classified as below.
|
|
140
185
|
if (e instanceof MnemomApiError && e.effectiveStatus === 400) {
|
|
141
|
-
const bare =
|
|
142
|
-
if (bare !== body)
|
|
143
|
-
|
|
186
|
+
const bare = legacyBody(body);
|
|
187
|
+
if (bare !== body && !/email|admin/i.test(e.code ?? "")) {
|
|
188
|
+
const r = await sendReportEmail(orgId, bare, deps);
|
|
189
|
+
return r.status === "sent" ? { ...r, limited: true } : r;
|
|
190
|
+
}
|
|
144
191
|
}
|
|
145
192
|
if (e instanceof MnemomApiError) {
|
|
146
193
|
// A 4xx that names the runner (the API refuses a send that would skip the runner) gets its own text.
|
|
194
|
+
if (e.code === "email_domain_not_allowed")
|
|
195
|
+
return { status: "failed", reason: "email_domain_not_allowed" };
|
|
196
|
+
if (/emails/i.test(e.code ?? ""))
|
|
197
|
+
return { status: "failed", reason: "emails_rejected" };
|
|
198
|
+
if (e.code === "admin_lookup_failed")
|
|
199
|
+
return { status: "failed", reason: "admins_not_found" };
|
|
147
200
|
if (e.effectiveStatus >= 400 && e.effectiveStatus < 500 && /runner/i.test(e.code ?? ""))
|
|
148
201
|
return { status: "failed", reason: "runner_not_emailed" };
|
|
149
202
|
return { status: "failed", reason: classifyStatus(e.effectiveStatus) };
|
|
@@ -158,8 +211,11 @@ export async function sendReportEmail(orgId, body, deps = {}) {
|
|
|
158
211
|
return { status: "failed", reason: "unexpected_response" };
|
|
159
212
|
if (recipients === 0)
|
|
160
213
|
return { status: "failed", reason: "no_recipients" };
|
|
161
|
-
// `skipped` lists the roles ("runner", "cto") that got no copy. The runner must always get the report.
|
|
214
|
+
// `skipped` lists the roles ("runner", "admin", "added", "cto") that got no copy. The runner must always get the report.
|
|
162
215
|
if (Array.isArray(o.skipped) && o.skipped.includes("runner"))
|
|
163
216
|
return { status: "failed", reason: "runner_not_emailed" };
|
|
164
|
-
|
|
217
|
+
const adminsMissed = Array.isArray(o.skipped) ? o.skipped.filter((s) => s === "admin").length : 0;
|
|
218
|
+
return o.status === "dry_run"
|
|
219
|
+
? { status: "dry_run" }
|
|
220
|
+
: { status: "sent", recipients, ...(adminsMissed > 0 ? { adminsMissed } : {}) };
|
|
165
221
|
}
|
package/dist/lib/scan/scan.d.ts
CHANGED
|
@@ -43,6 +43,8 @@ export interface RepoTotals {
|
|
|
43
43
|
prs: number;
|
|
44
44
|
reverts: number;
|
|
45
45
|
ciGreen: number;
|
|
46
|
+
/** PRs read and counted (not reverts, not skipped); the per-repo base of the CI-green share. */
|
|
47
|
+
examined: number;
|
|
46
48
|
}
|
|
47
49
|
export interface ScanResult {
|
|
48
50
|
coverage: Coverage;
|
package/dist/lib/scan/scan.js
CHANGED
|
@@ -104,7 +104,7 @@ export async function runLocalScan(deps, input) {
|
|
|
104
104
|
const bucket = (repo) => {
|
|
105
105
|
let b = byRepo.get(repo);
|
|
106
106
|
if (!b)
|
|
107
|
-
byRepo.set(repo, (b = { repo, prs: 0, reverts: 0, ciGreen: 0 }));
|
|
107
|
+
byRepo.set(repo, (b = { repo, prs: 0, reverts: 0, ciGreen: 0, examined: 0 }));
|
|
108
108
|
return b;
|
|
109
109
|
};
|
|
110
110
|
const totals = { prs: 0, reverts: 0, ciGreen: 0, examined: 0 };
|
|
@@ -142,6 +142,7 @@ export async function runLocalScan(deps, input) {
|
|
|
142
142
|
}
|
|
143
143
|
totals.prs += 1;
|
|
144
144
|
totals.examined += 1;
|
|
145
|
+
bucket(repo).examined += 1;
|
|
145
146
|
bucket(repo).prs += 1;
|
|
146
147
|
if (rec.ci === "success") {
|
|
147
148
|
totals.ciGreen += 1;
|
package/dist/lib/scan/share.d.ts
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
import type { ScanResult } from "./scan.js";
|
|
2
|
-
export type ShareLevel = "
|
|
2
|
+
export type ShareLevel = "full" | "analytics" | "none";
|
|
3
3
|
/** The level preselected at the prompt (Enter picks it). */
|
|
4
4
|
export declare const DEFAULT_SHARE_LEVEL: ShareLevel;
|
|
5
5
|
export declare const SHARE_OPTIONS: ReadonlyArray<{
|
|
6
6
|
level: ShareLevel;
|
|
7
7
|
label: string;
|
|
8
8
|
}>;
|
|
9
|
-
/** Shown just above the
|
|
9
|
+
/** Shown just above the prompt. The full report, repo names included, is emailed whatever is chosen here. */
|
|
10
10
|
export declare const SHARE_DISCLOSURE: string;
|
|
11
11
|
/** Asks the user, with Full preselected. Anything but a recognised answer (cancel, EOF, noise) is "none": fail closed. */
|
|
12
12
|
export declare function promptShareLevel(): Promise<ShareLevel>;
|
|
13
|
+
/** `summary` is the old name for `analytics`; the meaning is unchanged. */
|
|
13
14
|
export declare function parseShareLevel(v: string | undefined): ShareLevel | null;
|
|
14
|
-
/** Who receives the report.
|
|
15
|
+
/** Who receives the report. The runner comes from the Mnemom login; the server resolves the Org Admins. */
|
|
15
16
|
export interface ReportRecipients {
|
|
16
|
-
runner_email
|
|
17
|
-
|
|
17
|
+
runner_email?: string;
|
|
18
|
+
extra_emails: string[];
|
|
18
19
|
}
|
|
19
20
|
export interface SharePayload {
|
|
20
21
|
recipients: ReportRecipients;
|
|
@@ -30,6 +31,7 @@ export interface SharePayload {
|
|
|
30
31
|
coverage_complete: boolean;
|
|
31
32
|
/** Absent when no spend line may be shown. */
|
|
32
33
|
spend_line?: string;
|
|
34
|
+
/** Present at "full" only. */
|
|
33
35
|
repos?: Array<{
|
|
34
36
|
repo: string;
|
|
35
37
|
prs: number;
|
|
@@ -37,6 +39,6 @@ export interface SharePayload {
|
|
|
37
39
|
ci_green: number;
|
|
38
40
|
}>;
|
|
39
41
|
}
|
|
40
|
-
/** Builds only what the level allows. Never includes authors, titles, branches, bodies or tokens. The only person data is the
|
|
42
|
+
/** Builds only what the level allows. Never includes authors, titles, branches, bodies or tokens. The only person data is the report recipients. */
|
|
41
43
|
export declare function buildSharePayload(level: Exclude<ShareLevel, "none">, r: ScanResult, recipients: ReportRecipients, spendLine?: string | null): SharePayload;
|
|
42
44
|
export declare function buildSharePayload(level: ShareLevel, r: ScanResult, recipients: ReportRecipients, spendLine?: string | null): SharePayload | null;
|
package/dist/lib/scan/share.js
CHANGED
|
@@ -1,32 +1,49 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* "What may Mnemom keep on file?" (MNE-9634). This is a retention choice only; it never changes what is emailed. It is asked at
|
|
3
|
+
* the start, before any scan work. The scan itself is always complete: the full report, with the counts
|
|
4
|
+
* for each repo, is emailed to the runner, every Org Admin and anyone the runner adds, at every level.
|
|
5
|
+
*
|
|
6
|
+
* full Mnemom keeps the totals and the counts for each repo, by name, on file (the default).
|
|
7
|
+
* analytics Mnemom keeps the team totals only, with no repo names. `summary` is the old name for this
|
|
8
|
+
* and is accepted as an alias; it is never upgraded to full.
|
|
9
|
+
* none Mnemom keeps nothing on file. The report is still emailed.
|
|
10
|
+
*
|
|
11
|
+
* "none" is what a cancelled, empty-piped or non-interactive prompt resolves to (fail closed).
|
|
12
|
+
* Storing the shared payload on Mnemom's side is not built yet; the CLI only prints it.
|
|
5
13
|
*/
|
|
6
14
|
import { askSelect } from "../prompt.js";
|
|
7
15
|
/** The level preselected at the prompt (Enter picks it). */
|
|
8
16
|
export const DEFAULT_SHARE_LEVEL = "full";
|
|
9
17
|
export const SHARE_OPTIONS = [
|
|
10
|
-
{
|
|
11
|
-
|
|
12
|
-
|
|
18
|
+
{
|
|
19
|
+
level: "full",
|
|
20
|
+
label: "Full report (default): Mnemom keeps the totals and the counts for each repo, by name, on file",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
level: "analytics",
|
|
24
|
+
label: "Analytics: Mnemom keeps the team totals only on file, with no repo names",
|
|
25
|
+
},
|
|
26
|
+
{ level: "none", label: "Nothing: Mnemom keeps nothing on file" },
|
|
13
27
|
];
|
|
14
|
-
/** Shown just above the
|
|
28
|
+
/** Shown just above the prompt. The full report, repo names included, is emailed whatever is chosen here. */
|
|
15
29
|
export const SHARE_DISCLOSURE = [
|
|
16
|
-
"
|
|
17
|
-
"
|
|
30
|
+
"Whatever you choose, the full report, including repo names, is emailed to you, your org's admins and any addresses you added.",
|
|
31
|
+
"The next question is only about what Mnemom keeps on file. Pressing Enter chooses the Full report.",
|
|
18
32
|
].join("\n");
|
|
19
33
|
/** Asks the user, with Full preselected. Anything but a recognised answer (cancel, EOF, noise) is "none": fail closed. */
|
|
20
34
|
export async function promptShareLevel() {
|
|
21
35
|
// eslint-disable-next-line no-console -- consent text must show on stdout directly above the prompt (askSelect also prints with console.log)
|
|
22
36
|
console.log(SHARE_DISCLOSURE);
|
|
23
|
-
const answer = await askSelect("What may
|
|
37
|
+
const answer = await askSelect("What may Mnemom keep on file?", SHARE_OPTIONS.map((o) => o.label), SHARE_OPTIONS.findIndex((o) => o.level === DEFAULT_SHARE_LEVEL));
|
|
24
38
|
if (answer === null)
|
|
25
39
|
return "none";
|
|
26
40
|
return SHARE_OPTIONS.find((o) => o.label === answer)?.level ?? "none";
|
|
27
41
|
}
|
|
42
|
+
/** `summary` is the old name for `analytics`; the meaning is unchanged. */
|
|
28
43
|
export function parseShareLevel(v) {
|
|
29
|
-
|
|
44
|
+
if (v === "summary")
|
|
45
|
+
return "analytics";
|
|
46
|
+
return v === "analytics" || v === "full" || v === "none" ? v : null;
|
|
30
47
|
}
|
|
31
48
|
export function buildSharePayload(level, r, recipients,
|
|
32
49
|
/** undefined: the legacy line; null: no spend line; string: that line. */
|
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
export declare const ANTHROPIC_ADMIN_ORIGIN = "https://api.anthropic.com";
|
|
2
2
|
export declare const ADMIN_KEY_ENV = "ANTHROPIC_ADMIN_KEY";
|
|
3
|
+
/** The only paths a usage credential is ever sent to (design T18: fenced to report endpoints). */
|
|
4
|
+
export declare const REPORT_PATHS: readonly string[];
|
|
5
|
+
/** The fence, as one exported check so it is directly testable. Throws AnthropicUsageError for any other path. */
|
|
6
|
+
export declare function assertReportPath(path: string): void;
|
|
7
|
+
/** The exact auth header to send; same shape as mnemom-api src/org-scan/usage/credential-source.ts. */
|
|
8
|
+
export type AnthropicAuthHeader = {
|
|
9
|
+
header: "x-api-key";
|
|
10
|
+
value: string;
|
|
11
|
+
} | {
|
|
12
|
+
header: "authorization";
|
|
13
|
+
value: string;
|
|
14
|
+
};
|
|
3
15
|
export declare const DEFAULT_MAX_PAGES = 20;
|
|
4
16
|
/** One shared deadline for a request including its retries (not N x timeout). */
|
|
5
17
|
export declare const DEFAULT_TIMEOUT_MS = 15000;
|
|
6
18
|
export declare const DEFAULT_MAX_RETRIES = 2;
|
|
7
19
|
/** Shape check only; the key is never echoed, even on a mismatch. */
|
|
8
20
|
export declare const isAdminKeyShape: (k: unknown) => k is string;
|
|
9
|
-
export type UsageFailure = "key_invalid" | "key_refused" | "rate_limited" | "timeout" | "network" | "upstream" | "malformed" | "too_large";
|
|
21
|
+
export type UsageFailure = "key_invalid" | "key_refused" | "token_refused" | "rate_limited" | "timeout" | "network" | "upstream" | "malformed" | "too_large";
|
|
10
22
|
/** Fixed sentence for a failure reason. */
|
|
11
23
|
export declare const usageFailureText: (r: UsageFailure) => string;
|
|
12
24
|
/** Carries only a reason tag. Its message is the fixed sentence: nothing upstream can reach it. */
|
|
@@ -55,3 +67,10 @@ export declare function fetchMeasuredUsage(key: string | undefined, period: {
|
|
|
55
67
|
start: string;
|
|
56
68
|
end: string;
|
|
57
69
|
}, deps?: AnthropicUsageDeps): Promise<MeasuredUsage>;
|
|
70
|
+
/** A WIF access token as the bearer for the two report reads. Printable ASCII only, else key_invalid. */
|
|
71
|
+
export declare function bearerAuth(token: string): AnthropicAuthHeader;
|
|
72
|
+
/** The two reads with an already-resolved credential (pasted key or WIF bearer). Throws AnthropicUsageError only. */
|
|
73
|
+
export declare function fetchMeasuredUsageWithAuth(auth: AnthropicAuthHeader, period: {
|
|
74
|
+
start: string;
|
|
75
|
+
end: string;
|
|
76
|
+
}, deps?: AnthropicUsageDeps): Promise<MeasuredUsage>;
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// MNE-9298 Tier 1: the CTO's own Anthropic Admin key, used LOCALLY, to read measured usage. Port of
|
|
2
2
|
// mnemom-api src/org-scan/usage/usage-client.ts (two read-only GETs: usage_report/messages grouped by
|
|
3
|
-
// context_window, and cost_report) with the server-only parts (egress allowlist
|
|
3
|
+
// context_window, and cost_report) with the server-only parts (egress allowlist) dropped.
|
|
4
|
+
//
|
|
5
|
+
// MNE-9706: the reads take either credential, as the server's credential-source.ts does: the pasted Admin
|
|
6
|
+
// key (x-api-key) or a short-lived WIF access token (Authorization: Bearer, see anthropic-wif.ts). Either
|
|
7
|
+
// one is sent ONLY to the two report paths below; walk() refuses any other path (the fence, design T18:
|
|
8
|
+
// Anthropic's WIF scope for these reports is org:admin, so the fence has to live here).
|
|
4
9
|
//
|
|
5
10
|
// The key is held in memory for the length of the two reads. It is never stored, logged, written to disk,
|
|
6
11
|
// echoed, put in argv, a URL or an error message, and is sent only to api.anthropic.com (a constant, not
|
|
@@ -11,6 +16,13 @@ export const ADMIN_KEY_ENV = "ANTHROPIC_ADMIN_KEY";
|
|
|
11
16
|
const ANTHROPIC_VERSION = "2023-06-01";
|
|
12
17
|
const USAGE_PATH = "/v1/organizations/usage_report/messages";
|
|
13
18
|
const COST_PATH = "/v1/organizations/cost_report";
|
|
19
|
+
/** The only paths a usage credential is ever sent to (design T18: fenced to report endpoints). */
|
|
20
|
+
export const REPORT_PATHS = [USAGE_PATH, COST_PATH];
|
|
21
|
+
/** The fence, as one exported check so it is directly testable. Throws AnthropicUsageError for any other path. */
|
|
22
|
+
export function assertReportPath(path) {
|
|
23
|
+
if (!REPORT_PATHS.includes(path))
|
|
24
|
+
throw new AnthropicUsageError("malformed");
|
|
25
|
+
}
|
|
14
26
|
export const DEFAULT_MAX_PAGES = 20;
|
|
15
27
|
/** One shared deadline for a request including its retries (not N x timeout). */
|
|
16
28
|
export const DEFAULT_TIMEOUT_MS = 15_000;
|
|
@@ -22,6 +34,7 @@ export const isAdminKeyShape = (k) => typeof k === "string" && ADMIN_KEY_RE.test
|
|
|
22
34
|
const FAILURE_TEXT = {
|
|
23
35
|
key_invalid: "The Anthropic admin key is not an Admin API key (it should start with sk-ant-admin).",
|
|
24
36
|
key_refused: "Anthropic refused the admin key (401/403).",
|
|
37
|
+
token_refused: "Anthropic refused the workload identity token for the usage reports (401/403). The federation rule needs scope org:admin.",
|
|
25
38
|
rate_limited: "Anthropic rate limit reached.",
|
|
26
39
|
timeout: "Anthropic did not answer in time.",
|
|
27
40
|
network: "Anthropic was unreachable.",
|
|
@@ -96,14 +109,14 @@ function retryWait(res, attempt) {
|
|
|
96
109
|
return Number.isFinite(ra) && ra > 0 ? ra * 1000 : backoff(attempt);
|
|
97
110
|
}
|
|
98
111
|
class Reader {
|
|
99
|
-
|
|
112
|
+
auth;
|
|
100
113
|
fetchImpl;
|
|
101
114
|
sleep;
|
|
102
115
|
maxPages;
|
|
103
116
|
timeoutMs;
|
|
104
117
|
maxRetries;
|
|
105
|
-
constructor(
|
|
106
|
-
this.
|
|
118
|
+
constructor(auth, deps) {
|
|
119
|
+
this.auth = auth;
|
|
107
120
|
this.fetchImpl = deps.fetchImpl ?? ((...a) => fetch(...a));
|
|
108
121
|
this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
109
122
|
this.maxPages = deps.maxPages ?? DEFAULT_MAX_PAGES;
|
|
@@ -111,6 +124,8 @@ class Reader {
|
|
|
111
124
|
this.maxRetries = deps.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
112
125
|
}
|
|
113
126
|
async walk(path, base, parse, callMaxPages) {
|
|
127
|
+
// The fence: the credential goes to the two report paths and nowhere else.
|
|
128
|
+
assertReportPath(path);
|
|
114
129
|
const ceiling = typeof callMaxPages === "number" && Number.isFinite(callMaxPages) && callMaxPages >= 1
|
|
115
130
|
? Math.min(this.maxPages, Math.floor(callMaxPages))
|
|
116
131
|
: this.maxPages;
|
|
@@ -154,7 +169,7 @@ class Reader {
|
|
|
154
169
|
res = await this.fetchImpl(url, {
|
|
155
170
|
method: "GET",
|
|
156
171
|
headers: {
|
|
157
|
-
|
|
172
|
+
[this.auth.header]: this.auth.value,
|
|
158
173
|
"anthropic-version": ANTHROPIC_VERSION,
|
|
159
174
|
accept: "application/json",
|
|
160
175
|
},
|
|
@@ -185,7 +200,7 @@ class Reader {
|
|
|
185
200
|
}
|
|
186
201
|
await res.body?.cancel().catch(() => { });
|
|
187
202
|
if (res.status === 401 || res.status === 403)
|
|
188
|
-
throw new AnthropicUsageError("key_refused");
|
|
203
|
+
throw new AnthropicUsageError(this.auth.header === "x-api-key" ? "key_refused" : "token_refused");
|
|
189
204
|
const retryable = res.status === 429 || res.status >= 500;
|
|
190
205
|
const wait = retryable ? retryWait(res, attempt + 1) : 0;
|
|
191
206
|
if (retryable &&
|
|
@@ -209,11 +224,21 @@ export async function fetchMeasuredUsage(key, period, deps = {}) {
|
|
|
209
224
|
const k = key?.trim();
|
|
210
225
|
if (!k || !isAdminKeyShape(k))
|
|
211
226
|
throw new AnthropicUsageError("key_invalid");
|
|
227
|
+
return fetchMeasuredUsageWithAuth({ header: "x-api-key", value: k }, period, deps);
|
|
228
|
+
}
|
|
229
|
+
/** A WIF access token as the bearer for the two report reads. Printable ASCII only, else key_invalid. */
|
|
230
|
+
export function bearerAuth(token) {
|
|
231
|
+
if (!/^[\x21-\x7e]{8,4096}$/.test(token))
|
|
232
|
+
throw new AnthropicUsageError("key_invalid");
|
|
233
|
+
return { header: "authorization", value: `Bearer ${token}` };
|
|
234
|
+
}
|
|
235
|
+
/** The two reads with an already-resolved credential (pasted key or WIF bearer). Throws AnthropicUsageError only. */
|
|
236
|
+
export async function fetchMeasuredUsageWithAuth(auth, period, deps = {}) {
|
|
212
237
|
if (!validDay(period.start) || !validDay(period.end))
|
|
213
238
|
throw new AnthropicUsageError("malformed");
|
|
214
239
|
const startingAt = alignStartingAt(`${period.start}T00:00:00Z`);
|
|
215
240
|
const endingAt = new Date(Date.parse(`${period.end}T00:00:00Z`) + DAY_MS).toISOString();
|
|
216
|
-
const reader = new Reader(
|
|
241
|
+
const reader = new Reader(auth, deps);
|
|
217
242
|
const up = new URLSearchParams({
|
|
218
243
|
starting_at: startingAt,
|
|
219
244
|
ending_at: endingAt,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export declare const WIF_EXCHANGE_URL = "https://api.anthropic.com/v1/oauth/token";
|
|
2
|
+
export declare const WIF_ENV: {
|
|
3
|
+
readonly rule: "ANTHROPIC_FEDERATION_RULE_ID";
|
|
4
|
+
readonly org: "ANTHROPIC_ORGANIZATION_ID";
|
|
5
|
+
readonly serviceAccount: "ANTHROPIC_SERVICE_ACCOUNT_ID";
|
|
6
|
+
readonly workspace: "ANTHROPIC_WORKSPACE_ID";
|
|
7
|
+
};
|
|
8
|
+
/** Same ceiling the server held a WIF token to (credential-source.ts MAX_WIF_TOKEN_LIFETIME_MS). */
|
|
9
|
+
export declare const MAX_WIF_TOKEN_LIFETIME_MS = 300000;
|
|
10
|
+
export interface WifConfig {
|
|
11
|
+
federationRuleId: string;
|
|
12
|
+
organizationId: string;
|
|
13
|
+
serviceAccountId: string;
|
|
14
|
+
workspaceId?: string;
|
|
15
|
+
}
|
|
16
|
+
export type WifFailure = "config_invalid" | "assertion_unavailable" | "assertion_refused" | "exchange_refused" | "exchange_failed";
|
|
17
|
+
export declare const wifFailureText: (r: WifFailure) => string;
|
|
18
|
+
export declare const WIF_FALLBACK_NOTE = "Falling back to the pasted Anthropic Admin key.";
|
|
19
|
+
/** Carries only a reason tag; its message is the fixed sentence. */
|
|
20
|
+
export declare class WifError extends Error {
|
|
21
|
+
readonly reason: WifFailure;
|
|
22
|
+
constructor(reason: WifFailure);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Reads the WIF ids from env. None of the three required ids set: null (WIF is not set up, no message).
|
|
26
|
+
* Some set, or any malformed: WifError("config_invalid"), so a half-done setup is said out loud.
|
|
27
|
+
*/
|
|
28
|
+
export declare function readWifConfig(env: NodeJS.ProcessEnv): WifConfig | null;
|
|
29
|
+
export interface WifDeps {
|
|
30
|
+
/** Step 1 seam: returns the assertion for the org. Defaults to the mnemom-api route with the CLI's auth. */
|
|
31
|
+
mintAssertion?: (orgId: string) => Promise<unknown>;
|
|
32
|
+
/** Step 2 seam: the fetch used ONLY for the Anthropic exchange. */
|
|
33
|
+
fetchImpl?: typeof fetch;
|
|
34
|
+
now?: () => number;
|
|
35
|
+
}
|
|
36
|
+
export interface WifAccessToken {
|
|
37
|
+
accessToken: string;
|
|
38
|
+
/** Epoch ms, clamped to MAX_WIF_TOKEN_LIFETIME_MS from receipt. */
|
|
39
|
+
expiresAt: number;
|
|
40
|
+
}
|
|
41
|
+
/** Steps 1 and 2. Throws WifError only. */
|
|
42
|
+
export declare function obtainWifAccessToken(orgId: string, cfg: WifConfig, deps?: WifDeps): Promise<WifAccessToken>;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// MNE-9706 (S2.6, design T18): read the Anthropic Console through workload identity federation (WIF).
|
|
2
|
+
//
|
|
3
|
+
// Three steps, all from this machine:
|
|
4
|
+
// 1. Ask mnemom-api for a short-lived OIDC identity token (the "assertion") for the org, with the user's
|
|
5
|
+
// own CLI sign-in: POST /v1/orgs/{org_id}/scan/wif-assertion. Its sub is `mnemom:org:<org_id>`.
|
|
6
|
+
// 2. Exchange it DIRECTLY at Anthropic (POST https://api.anthropic.com/v1/oauth/token, RFC 7523
|
|
7
|
+
// jwt-bearer) for a short-lived access token. Mnemom never sees that token.
|
|
8
|
+
// 3. Hand the token to the usage reader, which sends it only to the two report paths (the fence).
|
|
9
|
+
//
|
|
10
|
+
// The customer's Console holds the federation issuer, service account and rule. The CLI needs only their
|
|
11
|
+
// non-secret ids, read from Anthropic's own variable names (ANTHROPIC_FEDERATION_RULE_ID,
|
|
12
|
+
// ANTHROPIC_ORGANIZATION_ID, ANTHROPIC_SERVICE_ACCOUNT_ID, optional ANTHROPIC_WORKSPACE_ID).
|
|
13
|
+
//
|
|
14
|
+
// The assertion and the access token live in memory for one scan and are never logged, stored, printed,
|
|
15
|
+
// put in argv, a URL or an error message. Every failure is a fixed sentence (WifFailure), so the caller can
|
|
16
|
+
// fall back to the pasted Admin key with a clear message.
|
|
17
|
+
import { MnemomApiError, postApi } from "../../api.js";
|
|
18
|
+
import { ANTHROPIC_ADMIN_ORIGIN } from "./anthropic-usage.js";
|
|
19
|
+
export const WIF_EXCHANGE_URL = `${ANTHROPIC_ADMIN_ORIGIN}/v1/oauth/token`;
|
|
20
|
+
export const WIF_ENV = {
|
|
21
|
+
rule: "ANTHROPIC_FEDERATION_RULE_ID",
|
|
22
|
+
org: "ANTHROPIC_ORGANIZATION_ID",
|
|
23
|
+
serviceAccount: "ANTHROPIC_SERVICE_ACCOUNT_ID",
|
|
24
|
+
workspace: "ANTHROPIC_WORKSPACE_ID",
|
|
25
|
+
};
|
|
26
|
+
/** Same ceiling the server held a WIF token to (credential-source.ts MAX_WIF_TOKEN_LIFETIME_MS). */
|
|
27
|
+
export const MAX_WIF_TOKEN_LIFETIME_MS = 300_000;
|
|
28
|
+
const EXCHANGE_TIMEOUT_MS = 15_000;
|
|
29
|
+
const WIF_TEXT = {
|
|
30
|
+
config_invalid: "The Anthropic workload identity settings are incomplete or malformed (federation rule, organization and service account ids).",
|
|
31
|
+
assertion_unavailable: "Mnemom could not issue a workload identity token for this org right now.",
|
|
32
|
+
assertion_refused: "Mnemom did not issue a workload identity token (sign in as an owner or admin of an org with Org Scan on).",
|
|
33
|
+
exchange_refused: "Anthropic refused the workload identity token. Check the federation rule in your Anthropic Console (its issuer, subject and scope org:admin).",
|
|
34
|
+
exchange_failed: "Anthropic's token exchange could not be completed.",
|
|
35
|
+
};
|
|
36
|
+
export const wifFailureText = (r) => WIF_TEXT[r];
|
|
37
|
+
export const WIF_FALLBACK_NOTE = "Falling back to the pasted Anthropic Admin key.";
|
|
38
|
+
/** Carries only a reason tag; its message is the fixed sentence. */
|
|
39
|
+
export class WifError extends Error {
|
|
40
|
+
reason;
|
|
41
|
+
constructor(reason) {
|
|
42
|
+
super(WIF_TEXT[reason]);
|
|
43
|
+
this.reason = reason;
|
|
44
|
+
this.name = "WifError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const RULE_RE = /^fdrl_[A-Za-z0-9]{1,128}$/;
|
|
48
|
+
const SVAC_RE = /^svac_[A-Za-z0-9]{1,128}$/;
|
|
49
|
+
const WRKSPC_RE = /^wrkspc_[A-Za-z0-9]{1,128}$/;
|
|
50
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
51
|
+
/**
|
|
52
|
+
* Reads the WIF ids from env. None of the three required ids set: null (WIF is not set up, no message).
|
|
53
|
+
* Some set, or any malformed: WifError("config_invalid"), so a half-done setup is said out loud.
|
|
54
|
+
*/
|
|
55
|
+
export function readWifConfig(env) {
|
|
56
|
+
const get = (k) => {
|
|
57
|
+
const v = env[k]?.trim();
|
|
58
|
+
return v ? v : undefined;
|
|
59
|
+
};
|
|
60
|
+
const rule = get(WIF_ENV.rule);
|
|
61
|
+
const org = get(WIF_ENV.org);
|
|
62
|
+
const svac = get(WIF_ENV.serviceAccount);
|
|
63
|
+
const ws = get(WIF_ENV.workspace);
|
|
64
|
+
if (!rule && !org && !svac)
|
|
65
|
+
return null;
|
|
66
|
+
if (!rule || !org || !svac)
|
|
67
|
+
throw new WifError("config_invalid");
|
|
68
|
+
if (!RULE_RE.test(rule) || !UUID_RE.test(org) || !SVAC_RE.test(svac))
|
|
69
|
+
throw new WifError("config_invalid");
|
|
70
|
+
if (ws !== undefined && !WRKSPC_RE.test(ws))
|
|
71
|
+
throw new WifError("config_invalid");
|
|
72
|
+
return {
|
|
73
|
+
federationRuleId: rule,
|
|
74
|
+
organizationId: org,
|
|
75
|
+
serviceAccountId: svac,
|
|
76
|
+
...(ws ? { workspaceId: ws } : {}),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const JWT_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
80
|
+
async function defaultMint(orgId) {
|
|
81
|
+
return postApi(`/v1/orgs/${encodeURIComponent(orgId)}/scan/wif-assertion`, {});
|
|
82
|
+
}
|
|
83
|
+
/** Steps 1 and 2. Throws WifError only. */
|
|
84
|
+
export async function obtainWifAccessToken(orgId, cfg, deps = {}) {
|
|
85
|
+
const now = deps.now ?? Date.now;
|
|
86
|
+
let assertion;
|
|
87
|
+
try {
|
|
88
|
+
const body = await (deps.mintAssertion ?? defaultMint)(orgId);
|
|
89
|
+
const a = body && typeof body === "object" ? body.assertion : undefined;
|
|
90
|
+
if (typeof a !== "string" || a.length > 16_384 || !JWT_RE.test(a))
|
|
91
|
+
throw new WifError("assertion_unavailable");
|
|
92
|
+
assertion = a;
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
if (e instanceof WifError)
|
|
96
|
+
throw e;
|
|
97
|
+
const status = e instanceof MnemomApiError ? e.effectiveStatus : 0;
|
|
98
|
+
// 401/403 is "not you"; 404 is the dark route (Org Scan off for this org). Both: no token for you.
|
|
99
|
+
throw new WifError(status === 401 || status === 403 || status === 404
|
|
100
|
+
? "assertion_refused"
|
|
101
|
+
: "assertion_unavailable");
|
|
102
|
+
}
|
|
103
|
+
const fetchImpl = deps.fetchImpl ?? ((...a) => fetch(...a));
|
|
104
|
+
let res;
|
|
105
|
+
try {
|
|
106
|
+
res = await fetchImpl(WIF_EXCHANGE_URL, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
109
|
+
body: JSON.stringify({
|
|
110
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
111
|
+
assertion,
|
|
112
|
+
federation_rule_id: cfg.federationRuleId,
|
|
113
|
+
organization_id: cfg.organizationId,
|
|
114
|
+
service_account_id: cfg.serviceAccountId,
|
|
115
|
+
...(cfg.workspaceId ? { workspace_id: cfg.workspaceId } : {}),
|
|
116
|
+
}),
|
|
117
|
+
// A redirect would carry the assertion elsewhere: refuse to follow one.
|
|
118
|
+
redirect: "error",
|
|
119
|
+
signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// Never surface the thrown value: it can carry the request.
|
|
124
|
+
throw new WifError("exchange_failed");
|
|
125
|
+
}
|
|
126
|
+
if (!res.ok) {
|
|
127
|
+
await res.body?.cancel().catch(() => { });
|
|
128
|
+
throw new WifError(res.status === 400 || res.status === 401 || res.status === 403
|
|
129
|
+
? "exchange_refused"
|
|
130
|
+
: "exchange_failed");
|
|
131
|
+
}
|
|
132
|
+
let json;
|
|
133
|
+
try {
|
|
134
|
+
json = await res.json();
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
throw new WifError("exchange_failed");
|
|
138
|
+
}
|
|
139
|
+
const b = json && typeof json === "object" ? json : {};
|
|
140
|
+
const token = b.access_token;
|
|
141
|
+
if (typeof token !== "string" || !/^[\x21-\x7e]{8,4096}$/.test(token))
|
|
142
|
+
throw new WifError("exchange_failed");
|
|
143
|
+
if (b.token_type !== undefined && String(b.token_type).toLowerCase() !== "bearer")
|
|
144
|
+
throw new WifError("exchange_failed");
|
|
145
|
+
const secs = typeof b.expires_in === "number" && Number.isFinite(b.expires_in) && b.expires_in > 0
|
|
146
|
+
? b.expires_in
|
|
147
|
+
: 300;
|
|
148
|
+
const t = now();
|
|
149
|
+
return {
|
|
150
|
+
accessToken: token,
|
|
151
|
+
expiresAt: Math.min(t + secs * 1000, t + MAX_WIF_TOKEN_LIFETIME_MS),
|
|
152
|
+
};
|
|
153
|
+
}
|