@intentius/chant 0.52.2 → 0.53.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/cdk/advise.d.ts +29 -0
- package/dist/cdk/advise.d.ts.map +1 -0
- package/dist/cdk/assembly.d.ts +38 -0
- package/dist/cdk/assembly.d.ts.map +1 -0
- package/dist/cdk/graph.d.ts +68 -0
- package/dist/cdk/graph.d.ts.map +1 -0
- package/dist/cdk/tier-map.d.ts +44 -0
- package/dist/cdk/tier-map.d.ts.map +1 -0
- package/dist/cdk/types.d.ts +114 -0
- package/dist/cdk/types.d.ts.map +1 -0
- package/dist/cli/commands/carve-apply.d.ts.map +1 -1
- package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
- package/dist/cli/commands/carve-emit.d.ts.map +1 -1
- package/dist/cli/commands/carve.d.ts +48 -6
- package/dist/cli/commands/carve.d.ts.map +1 -1
- package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
- package/dist/cli/handlers/carve.d.ts +5 -4
- package/dist/cli/handlers/carve.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +10 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/op-progress.d.ts +37 -4
- package/dist/cli/handlers/op-progress.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +61 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +9 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/identity.d.ts +196 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +41 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/converge-ledger.d.ts +81 -1
- package/dist/lifecycle/converge-ledger.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +43 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/terraform/adopt-state.d.ts +17 -66
- package/dist/terraform/adopt-state.d.ts.map +1 -1
- package/dist/terraform/aws-resources.d.ts +1 -1
- package/dist/terraform/carve-provider.d.ts +142 -0
- package/dist/terraform/carve-provider.d.ts.map +1 -0
- package/dist/terraform/carve.d.ts +36 -3
- package/dist/terraform/carve.d.ts.map +1 -1
- package/dist/terraform/emit-source.d.ts +25 -0
- package/dist/terraform/emit-source.d.ts.map +1 -0
- package/dist/terraform/graduate.d.ts +11 -1
- package/dist/terraform/graduate.d.ts.map +1 -1
- package/dist/terraform/providers/aws.d.ts +19 -0
- package/dist/terraform/providers/aws.d.ts.map +1 -0
- package/dist/terraform/providers/gcp.d.ts +41 -0
- package/dist/terraform/providers/gcp.d.ts.map +1 -0
- package/dist/terraform/providers/index.d.ts +15 -0
- package/dist/terraform/providers/index.d.ts.map +1 -0
- package/dist/terraform/providers/kubernetes.d.ts +29 -0
- package/dist/terraform/providers/kubernetes.d.ts.map +1 -0
- package/dist/terraform/score.d.ts +70 -4
- package/dist/terraform/score.d.ts.map +1 -1
- package/dist/terraform/tier-map.d.ts +30 -35
- package/dist/terraform/tier-map.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +6 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cdk/__fixtures__/cdk.out/AppStack.template.json +171 -0
- package/src/cdk/__fixtures__/cdk.out/DataStack.template.json +90 -0
- package/src/cdk/__fixtures__/cdk.out/cdk.out +1 -0
- package/src/cdk/__fixtures__/cdk.out/manifest.json +30 -0
- package/src/cdk/__fixtures__/cdk.out/tree.json +201 -0
- package/src/cdk/__fixtures__/cdk.out-dummy/LookupStack.template.json +29 -0
- package/src/cdk/__fixtures__/cdk.out-dummy/manifest.json +26 -0
- package/src/cdk/advise.test.ts +208 -0
- package/src/cdk/advise.ts +44 -0
- package/src/cdk/assembly.ts +133 -0
- package/src/cdk/graph.test.ts +206 -0
- package/src/cdk/graph.ts +525 -0
- package/src/cdk/tier-map.ts +71 -0
- package/src/cdk/types.ts +115 -0
- package/src/cli/commands/carve-apply.ts +20 -4
- package/src/cli/commands/carve-bridge.ts +9 -5
- package/src/cli/commands/carve-emit-k8s.test.ts +262 -0
- package/src/cli/commands/carve-emit-provider.test.ts +207 -0
- package/src/cli/commands/carve-emit.ts +28 -24
- package/src/cli/commands/carve.ts +139 -36
- package/src/cli/handlers/carve-emit.ts +8 -3
- package/src/cli/handlers/carve.ts +8 -7
- package/src/cli/handlers/lifecycle.test.ts +187 -1
- package/src/cli/handlers/lifecycle.ts +125 -1
- package/src/cli/handlers/op-progress.test.ts +67 -1
- package/src/cli/handlers/op-progress.ts +70 -14
- package/src/cli/handlers/operator.test.ts +302 -1
- package/src/cli/handlers/operator.ts +193 -5
- package/src/cli/handlers/run.ts +11 -13
- package/src/cli/main.test.ts +19 -0
- package/src/cli/main.ts +38 -10
- package/src/cli/registry.ts +9 -1
- package/src/identity.test.ts +199 -0
- package/src/identity.ts +346 -0
- package/src/index.ts +1 -0
- package/src/lexicon.ts +53 -0
- package/src/lifecycle/converge-ledger.test.ts +110 -0
- package/src/lifecycle/converge-ledger.ts +110 -2
- package/src/lifecycle/gate-ledger.test.ts +81 -1
- package/src/lifecycle/gate-ledger.ts +64 -1
- package/src/terraform/__fixtures__/gcp-estate/main.tf +60 -0
- package/src/terraform/adopt-state.test.ts +131 -0
- package/src/terraform/adopt-state.ts +19 -167
- package/src/terraform/aws-resources.test.ts +38 -22
- package/src/terraform/aws-resources.ts +1 -1
- package/src/terraform/carve-provider.test.ts +155 -0
- package/src/terraform/carve-provider.ts +237 -0
- package/src/terraform/carve.test.ts +55 -1
- package/src/terraform/carve.ts +0 -0
- package/src/terraform/emit-source.ts +39 -0
- package/src/terraform/graduate.test.ts +37 -0
- package/src/terraform/graduate.ts +55 -7
- package/src/terraform/graph.ts +3 -3
- package/src/terraform/providers/aws.ts +169 -0
- package/src/terraform/providers/gcp.test.ts +228 -0
- package/src/terraform/providers/gcp.ts +329 -0
- package/src/terraform/providers/index.ts +21 -0
- package/src/terraform/providers/kubernetes.ts +224 -0
- package/src/terraform/score.ts +111 -25
- package/src/terraform/tier-map.ts +45 -107
- package/src/terraform/types.ts +6 -0
package/src/cli/main.ts
CHANGED
|
@@ -23,14 +23,14 @@ import { runCarveAdvise, runCarveUnknown } from "./handlers/carve";
|
|
|
23
23
|
import { runCarveEmit } from "./handlers/carve-emit";
|
|
24
24
|
import { runCarveBridge } from "./handlers/carve-bridge";
|
|
25
25
|
import { runCarveApply } from "./handlers/carve-apply";
|
|
26
|
-
import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecycleRollback, runLifecyclePlan, runLifecycleAffected, runLifecycleLog, runLifecycleTeardown, runLifecycleUnknown } from "./handlers/lifecycle";
|
|
26
|
+
import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecycleRollback, runLifecyclePlan, runLifecycleAffected, runLifecycleLog, runLifecycleTeardown, runLifecycleWhoami, runLifecycleUnknown } from "./handlers/lifecycle";
|
|
27
27
|
import { runComponentsStatus, runComponentsReleaseRecord, runComponentsExport, runComponentsUnknown } from "./handlers/components";
|
|
28
28
|
import { runScenarioCheck, runScenarioUnknown } from "./handlers/scenario";
|
|
29
29
|
import { runGraph } from "./handlers/graph";
|
|
30
30
|
import { runExplain } from "./handlers/explain";
|
|
31
31
|
import { runSearch } from "./handlers/search";
|
|
32
32
|
import { runOp, runOpList, runOpStatus, runOpSignal, runOpCancel, runOpLog } from "./handlers/run";
|
|
33
|
-
import { runOperator, runOperatorStatus, runApprove } from "./handlers/operator";
|
|
33
|
+
import { runOperator, runOperatorStatus, runOperatorLog, runApprove } from "./handlers/operator";
|
|
34
34
|
import { runEmulator } from "./handlers/emulator";
|
|
35
35
|
import { splitJoinedFlags, dispatchCommandGroup, collectCommandGroups, formatCommandGroupsHelp, type CommandGroup } from "./command-group";
|
|
36
36
|
import type { LexiconPlugin } from "../lexicon";
|
|
@@ -388,6 +388,16 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
388
388
|
result.once = true;
|
|
389
389
|
} else if (arg === "--note") {
|
|
390
390
|
result.note = args[++i];
|
|
391
|
+
} else if (arg === "--url") {
|
|
392
|
+
result.url = args[++i];
|
|
393
|
+
} else if (arg === "--op") {
|
|
394
|
+
result.op = args[++i];
|
|
395
|
+
} else if (arg === "--since") {
|
|
396
|
+
result.since = args[++i];
|
|
397
|
+
} else if (arg === "--limit") {
|
|
398
|
+
// Parsed here, validated by the handler — `parseArgs` reports shape, not
|
|
399
|
+
// policy, the same split every other value flag here uses.
|
|
400
|
+
result.limit = Number(args[++i]);
|
|
391
401
|
} else if (arg.startsWith("--")) {
|
|
392
402
|
// chant #1127 — every recognized flag is matched above; anything left
|
|
393
403
|
// starting with `--` is unrecognized, whether it arrived bare
|
|
@@ -461,10 +471,12 @@ Commands:
|
|
|
461
471
|
--all-projects)
|
|
462
472
|
migrate <file> Translate a workflow between lexicons
|
|
463
473
|
(default: --from github --to gitlab)
|
|
464
|
-
carve advise Read-only
|
|
465
|
-
--from <
|
|
474
|
+
carve advise Read-only peelability advisor: rank which resources
|
|
475
|
+
--from <dir> are cheap to carve into native chant
|
|
466
476
|
(--json, --report <path>). Emits nothing, changes nothing.
|
|
467
|
-
|
|
477
|
+
--from a Terraform dir needs @cdktf/hcl2json
|
|
478
|
+
(npm install -D @cdktf/hcl2json); --from a CDK cloud
|
|
479
|
+
assembly (cdk.out) needs nothing and ranks constructs.
|
|
468
480
|
carve emit Adopt a selected TF resource into chant source + report
|
|
469
481
|
--from <tf-dir> its boundary. --state <tfstate> adopts offline
|
|
470
482
|
--select <addr> (recommended for TF-managed resources); --env
|
|
@@ -522,11 +534,21 @@ Ops:
|
|
|
522
534
|
ConvergeOp, read from the chant/lifecycle orphan
|
|
523
535
|
branch alone — no daemon needs to be running
|
|
524
536
|
(--env <env>, --json)
|
|
537
|
+
operator log Converge tick history and the gate resolutions
|
|
538
|
+
against it, merged into one timestamp-ordered
|
|
539
|
+
timeline, from the same orphan branch (--env <env>,
|
|
540
|
+
--op <name>, --since <iso>, --limit <n>, --json).
|
|
541
|
+
--json also carries the count of ledger lines that
|
|
542
|
+
were unreadable, so a short timeline is never
|
|
543
|
+
silently short
|
|
525
544
|
approve <op> <gate> Record a gate's out-of-band resolution fact
|
|
526
|
-
(--actor <name>, --note <text>) — the
|
|
527
|
-
counterpart to a converge tick's
|
|
528
|
-
outcome; see the pending-gates list in
|
|
529
|
-
status.
|
|
545
|
+
(--actor <name>, --note <text>, --url <url>) — the
|
|
546
|
+
durable counterpart to a converge tick's
|
|
547
|
+
gate-as-fact outcome; see the pending-gates list in
|
|
548
|
+
operator status. --url is the PR/MR the resolution
|
|
549
|
+
happened at, recorded typed rather than as free text,
|
|
550
|
+
and defaults to the PR/MR of the surrounding CI job.
|
|
551
|
+
Does not itself unblock the gated op's local
|
|
530
552
|
dispatch (re-run --temporal, or merge its PR)
|
|
531
553
|
|
|
532
554
|
graph Show Op dependency graph (--stacks for cross-stack order,
|
|
@@ -556,6 +578,9 @@ Lifecycle (alias: lc):
|
|
|
556
578
|
lifecycle plan <env> Typed change set (create/update/delete/adopt) vs live
|
|
557
579
|
lifecycle affected Stacks a change affects (--base <ref> [--include-dependents])
|
|
558
580
|
--json: emit the ChangeSet as JSON
|
|
581
|
+
lifecycle whoami <env> Who chant would act as in each configured lexicon,
|
|
582
|
+
and what that principal is scoped to — read-only,
|
|
583
|
+
before anything acts (--json, --strict)
|
|
559
584
|
lifecycle teardown <env> Plan what deleting the environment would remove —
|
|
560
585
|
marker-scoped (this project's stack + env); --yes
|
|
561
586
|
executes the plan (production-like names also need
|
|
@@ -646,7 +671,8 @@ Options:
|
|
|
646
671
|
--from <name> Source lexicon for migrate (default: github)
|
|
647
672
|
--to <name> Target lexicon for migrate (default: gitlab)
|
|
648
673
|
--emit <fmt> Migration output format: yaml (default) or ts
|
|
649
|
-
--strict Escalate needs-review/validation to errors (migrate)
|
|
674
|
+
--strict Escalate needs-review/validation to errors (migrate);
|
|
675
|
+
exit nonzero on an unresolved identity (lifecycle whoami)
|
|
650
676
|
--validate Run external validator (glci/glab) after migrate
|
|
651
677
|
--use-composites Rewrite to composite calls when patterns match (migrate)
|
|
652
678
|
--components Target discovered Component declarations instead of
|
|
@@ -869,6 +895,7 @@ const registry: CommandDef[] = [
|
|
|
869
895
|
{ name: "run", handler: runOp },
|
|
870
896
|
|
|
871
897
|
{ name: "operator status", handler: runOperatorStatus },
|
|
898
|
+
{ name: "operator log", handler: runOperatorLog },
|
|
872
899
|
{ name: "operator", handler: runOperator },
|
|
873
900
|
{ name: "approve", handler: runApprove },
|
|
874
901
|
|
|
@@ -882,6 +909,7 @@ const registry: CommandDef[] = [
|
|
|
882
909
|
{ name: "lifecycle rollback", handler: runLifecycleRollback },
|
|
883
910
|
{ name: "lifecycle plan", requiresPlugins: true, handler: runLifecyclePlan },
|
|
884
911
|
{ name: "lifecycle affected", requiresPlugins: true, handler: runLifecycleAffected },
|
|
912
|
+
{ name: "lifecycle whoami", requiresPlugins: true, handler: runLifecycleWhoami },
|
|
885
913
|
{ name: "lifecycle teardown", requiresPlugins: true, handler: runLifecycleTeardown },
|
|
886
914
|
{ name: "lifecycle log", handler: runLifecycleLog },
|
|
887
915
|
|
package/src/cli/registry.ts
CHANGED
|
@@ -285,8 +285,16 @@ export interface ParsedArgs {
|
|
|
285
285
|
leaseTtl?: string;
|
|
286
286
|
/** `chant operator --once` (#1485) — run a single round and exit, instead of looping until Ctrl-C. Also the offline test/cron-invoker story. */
|
|
287
287
|
once?: boolean;
|
|
288
|
-
/** `chant approve <op> <gate> --note <text>` (#1485) — optional free-text
|
|
288
|
+
/** `chant approve <op> <gate> --note <text>` (#1485) — optional free-text prose recorded on the gate-resolution fact. The PR link belongs in `--url` since #2028; this is for everything that isn't the link. */
|
|
289
289
|
note?: string;
|
|
290
|
+
/** `chant operator log --op <name>` (#2029) — restrict the tick history to one ConvergeOp by name. Omitted, every discovered ConvergeOp's ticks are merged into one timeline. */
|
|
291
|
+
op?: string;
|
|
292
|
+
/** `chant operator log --since <iso>` (#2029) — only entries at or after this ISO-8601 instant. */
|
|
293
|
+
since?: string;
|
|
294
|
+
/** `chant operator log --limit <n>` (#2029) — keep only the newest n entries (still printed oldest-first). */
|
|
295
|
+
limit?: number;
|
|
296
|
+
/** `chant approve <op> <gate> --url <url>` (#2028) — the address this resolution happened at (the PR/MR that carried the change), recorded typed on the gate-resolution fact so a reader is not sniffing `--note` for something link-shaped. Defaults to the PR/MR the surrounding CI job is for, when there is one. Must be an absolute http/https URL. */
|
|
297
|
+
url?: string;
|
|
290
298
|
}
|
|
291
299
|
|
|
292
300
|
/**
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
describeIdentities,
|
|
4
|
+
identityRowFor,
|
|
5
|
+
identityStatusText,
|
|
6
|
+
isUnresolvedIdentity,
|
|
7
|
+
redactCredentialMaterial,
|
|
8
|
+
redactIdentityRow,
|
|
9
|
+
REDACTED,
|
|
10
|
+
type IdentityPlugin,
|
|
11
|
+
} from "./identity";
|
|
12
|
+
|
|
13
|
+
describe("the identity tri-state (#1982)", () => {
|
|
14
|
+
test("a lexicon with no describeIdentity is not-reported, never an empty identity", async () => {
|
|
15
|
+
const rows = await describeIdentities([{ name: "github" }], { environment: "prod" }, {});
|
|
16
|
+
expect(rows).toEqual([{ lexicon: "github", status: "not-reported" }]);
|
|
17
|
+
expect(rows[0].identity).toBeUndefined();
|
|
18
|
+
expect(identityStatusText(rows[0])).toContain("not reported");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("a resolved identity carries principal, scope, source and endpoint", async () => {
|
|
22
|
+
const plugin: IdentityPlugin = {
|
|
23
|
+
name: "aws",
|
|
24
|
+
describeIdentity: async () => ({
|
|
25
|
+
identity: "arn:aws:sts::491500000000:assumed-role/deploy/ci",
|
|
26
|
+
scope: "491500000000 us-east-1",
|
|
27
|
+
source: "env AWS_ACCESS_KEY_ID",
|
|
28
|
+
endpoint: "https://sts.us-east-1.amazonaws.com/",
|
|
29
|
+
}),
|
|
30
|
+
};
|
|
31
|
+
const [row] = await describeIdentities([plugin], { environment: "prod" }, {});
|
|
32
|
+
expect(row).toEqual({
|
|
33
|
+
lexicon: "aws",
|
|
34
|
+
status: "reported",
|
|
35
|
+
identity: "arn:aws:sts::491500000000:assumed-role/deploy/ci",
|
|
36
|
+
scope: "491500000000 us-east-1",
|
|
37
|
+
source: "env AWS_ACCESS_KEY_ID",
|
|
38
|
+
endpoint: "https://sts.us-east-1.amazonaws.com/",
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("no credentials and could-not-determine are distinguishable answers", async () => {
|
|
43
|
+
const rows = await describeIdentities(
|
|
44
|
+
[
|
|
45
|
+
{ name: "aws", describeIdentity: async () => ({ unresolved: { reason: "no-credentials" } }) },
|
|
46
|
+
{
|
|
47
|
+
name: "k8s",
|
|
48
|
+
describeIdentity: async () => ({ unresolved: { reason: "read-failed", detail: "connection refused" } }),
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
{ environment: "prod" },
|
|
52
|
+
{},
|
|
53
|
+
);
|
|
54
|
+
expect(rows[0]).toEqual({ lexicon: "aws", status: "unresolved", reason: "no-credentials" });
|
|
55
|
+
expect(identityStatusText(rows[0])).toContain("no identity is configured");
|
|
56
|
+
expect(rows[1]).toEqual({
|
|
57
|
+
lexicon: "k8s",
|
|
58
|
+
status: "unresolved",
|
|
59
|
+
reason: "read-failed",
|
|
60
|
+
detail: "connection refused",
|
|
61
|
+
});
|
|
62
|
+
expect(identityStatusText(rows[1])).toContain("self-query failed");
|
|
63
|
+
// The point of the distinction: "nothing is configured" and "I could not
|
|
64
|
+
// find out" must never render as the same row.
|
|
65
|
+
expect(identityStatusText(rows[0])).not.toBe(identityStatusText(rows[1]));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("an empty principal is refused rather than reported as an identity", () => {
|
|
69
|
+
const row = identityRowFor("gcp", { identity: " ", scope: "acme-prod", source: "ADC" }, {});
|
|
70
|
+
expect(row.status).toBe("unresolved");
|
|
71
|
+
expect(row.reason).toBe("read-failed");
|
|
72
|
+
expect(row.identity).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a lexicon that throws degrades to read-failed and never fails the run", async () => {
|
|
76
|
+
const rows = await describeIdentities(
|
|
77
|
+
[
|
|
78
|
+
{
|
|
79
|
+
name: "azure",
|
|
80
|
+
describeIdentity: async () => {
|
|
81
|
+
throw new Error("az CLI is not installed");
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{ name: "aws", describeIdentity: async () => ({ identity: "arn:x", scope: "s", source: "env" }) },
|
|
85
|
+
],
|
|
86
|
+
{ environment: "prod" },
|
|
87
|
+
{},
|
|
88
|
+
);
|
|
89
|
+
expect(rows[0]).toEqual({
|
|
90
|
+
lexicon: "azure",
|
|
91
|
+
status: "unresolved",
|
|
92
|
+
reason: "read-failed",
|
|
93
|
+
detail: "az CLI is not installed",
|
|
94
|
+
});
|
|
95
|
+
expect(rows[1].status).toBe("reported");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("rows come back in the order the lexicons were configured", async () => {
|
|
99
|
+
const names = ["k8s", "aws", "gcp", "github"];
|
|
100
|
+
const rows = await describeIdentities(
|
|
101
|
+
names.map((name) => ({ name })),
|
|
102
|
+
{ environment: "prod" },
|
|
103
|
+
{},
|
|
104
|
+
);
|
|
105
|
+
expect(rows.map((r) => r.lexicon)).toEqual(names);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("the environment, region and cwd reach the lexicon unchanged", async () => {
|
|
109
|
+
const seen: unknown[] = [];
|
|
110
|
+
await describeIdentities(
|
|
111
|
+
[
|
|
112
|
+
{
|
|
113
|
+
name: "aws",
|
|
114
|
+
describeIdentity: async (options) => {
|
|
115
|
+
seen.push(options);
|
|
116
|
+
return { identity: "arn:x", scope: "s", source: "env" };
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
{ environment: "prod", region: "eu-west-1", cwd: "/repo" },
|
|
121
|
+
{},
|
|
122
|
+
);
|
|
123
|
+
expect(seen).toEqual([{ environment: "prod", region: "eu-west-1", cwd: "/repo" }]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("isUnresolvedIdentity discriminates the two return shapes", () => {
|
|
127
|
+
expect(isUnresolvedIdentity({ unresolved: { reason: "no-binding" } })).toBe(true);
|
|
128
|
+
expect(isUnresolvedIdentity({ identity: "a", scope: "b", source: "c" })).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("no credential reaches the output (#1982)", () => {
|
|
133
|
+
test("a value held in a credential-named env var is redacted wherever it appears", () => {
|
|
134
|
+
const env = { GITHUB_TOKEN: "ghp_averyrealtokenvalue", HOME: "/Users/x" };
|
|
135
|
+
expect(redactCredentialMaterial("authorized by ghp_averyrealtokenvalue", env)).toBe(
|
|
136
|
+
`authorized by ${REDACTED}`,
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("every free-text field of a row is scrubbed, not just the identity", () => {
|
|
141
|
+
const env = { AWS_SECRET_ACCESS_KEY: "wJalrXUtnFEMIsecretKEY" };
|
|
142
|
+
const row = redactIdentityRow(
|
|
143
|
+
{
|
|
144
|
+
lexicon: "aws",
|
|
145
|
+
status: "reported",
|
|
146
|
+
identity: "wJalrXUtnFEMIsecretKEY",
|
|
147
|
+
scope: "wJalrXUtnFEMIsecretKEY",
|
|
148
|
+
source: "signed with wJalrXUtnFEMIsecretKEY",
|
|
149
|
+
endpoint: "https://sts.amazonaws.com/?k=wJalrXUtnFEMIsecretKEY",
|
|
150
|
+
detail: "wJalrXUtnFEMIsecretKEY",
|
|
151
|
+
},
|
|
152
|
+
env,
|
|
153
|
+
);
|
|
154
|
+
for (const field of [row.identity, row.scope, row.source, row.endpoint, row.detail]) {
|
|
155
|
+
expect(field).not.toContain("wJalrXUtnFEMIsecretKEY");
|
|
156
|
+
expect(field).toContain(REDACTED);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("a short env value is not treated as a credential", () => {
|
|
161
|
+
// Redacting "dev" out of every identity would be worse than the leak.
|
|
162
|
+
expect(redactCredentialMaterial("cluster dev-a", { AUTH_TOKEN: "dev" })).toBe("cluster dev-a");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("literal credential shapes are redacted with no env var involved", () => {
|
|
166
|
+
const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.dBjftJeZ4CVPmB92K27u";
|
|
167
|
+
expect(redactCredentialMaterial(jwt, {})).toBe(REDACTED);
|
|
168
|
+
expect(redactCredentialMaterial("Bearer abcdefghijklmnopqrstuvwxyz012345", {})).toBe(REDACTED);
|
|
169
|
+
expect(
|
|
170
|
+
redactCredentialMaterial("-----BEGIN RSA PRIVATE KEY-----\nAAAA\n-----END RSA PRIVATE KEY-----", {}),
|
|
171
|
+
).toBe(REDACTED);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("real principals survive redaction unchanged", () => {
|
|
175
|
+
// A mangled principal is a wrong answer, not a safe one. Nothing here is
|
|
176
|
+
// entropy-scored, so these pass through whole.
|
|
177
|
+
const env = { AWS_SESSION_TOKEN: "FwoGZXIvYXdzEBYaDLONGSESSIONTOKEN" };
|
|
178
|
+
for (const principal of [
|
|
179
|
+
"arn:aws:sts::491500000000:assumed-role/deploy/ci",
|
|
180
|
+
"system:serviceaccount:chant:deployer",
|
|
181
|
+
"deploy@acme.iam.gserviceaccount.com",
|
|
182
|
+
"https://prod-eks-a.eu-west-1.eks.amazonaws.com",
|
|
183
|
+
"491500000000 us-east-1",
|
|
184
|
+
]) {
|
|
185
|
+
expect(redactCredentialMaterial(principal, env)).toBe(principal);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("an identity built out of the session token is redacted, not printed", () => {
|
|
190
|
+
const env = { AWS_SESSION_TOKEN: "FwoGZXIvYXdzEBYaDLONGSESSIONTOKEN" };
|
|
191
|
+
const row = identityRowFor(
|
|
192
|
+
"aws",
|
|
193
|
+
{ identity: "FwoGZXIvYXdzEBYaDLONGSESSIONTOKEN", scope: "us-east-1", source: "env" },
|
|
194
|
+
env,
|
|
195
|
+
);
|
|
196
|
+
expect(row.identity).toBe(REDACTED);
|
|
197
|
+
expect(row.identity).not.toContain("FwoGZXIvYXdz");
|
|
198
|
+
});
|
|
199
|
+
});
|
package/src/identity.ts
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The identity contract (#1982) — which principal chant would act as in each
|
|
3
|
+
* substrate, reported before it acts.
|
|
4
|
+
*
|
|
5
|
+
* Every native observer already resolves this. `ObserverAdapter.bind()`
|
|
6
|
+
* (./observation.ts) reaches the provider on the applier's own transport, the
|
|
7
|
+
* k8s connector resolves a kubeconfig context and says which binding produced
|
|
8
|
+
* it, the aws read client resolves a region and an endpoint. All of it is
|
|
9
|
+
* discarded. The only way the answer has ever surfaced is as an `unobserved`
|
|
10
|
+
* entry with reason `no-credentials`, or — worse — as a successful read
|
|
11
|
+
* against the wrong account.
|
|
12
|
+
*
|
|
13
|
+
* A project spanning aws, k8s, gcp and github reaches four substrates with
|
|
14
|
+
* four credential sets, and nothing says which identity each one resolves to.
|
|
15
|
+
* `chant lifecycle whoami <env>` asks each configured lexicon that question
|
|
16
|
+
* and prints one row per lexicon.
|
|
17
|
+
*
|
|
18
|
+
* ## The tri-state
|
|
19
|
+
*
|
|
20
|
+
* The same discipline the observation contract draws between "absent" and
|
|
21
|
+
* "not observed" applies here, because the same mistake is available: an empty
|
|
22
|
+
* identity reads as "nobody", and "nobody" is not what "I could not find out"
|
|
23
|
+
* means.
|
|
24
|
+
*
|
|
25
|
+
* - **REPORTED** — the lexicon asked the substrate and it answered. A
|
|
26
|
+
* principal, a scope, and where the binding came from.
|
|
27
|
+
* - **UNRESOLVED** — the lexicon tried and could not, carrying a total
|
|
28
|
+
* {@link UnobservedReason}. `no-credentials` is "no identity is
|
|
29
|
+
* configured"; `no-binding` and `read-failed` are "could not determine".
|
|
30
|
+
* Those are different answers and the reason keeps them apart.
|
|
31
|
+
* - **NOT REPORTED** — the lexicon implements no {@link
|
|
32
|
+
* import("./lexicon").LexiconPlugin.describeIdentity}. It answers for
|
|
33
|
+
* nothing, which is honest; it is never rendered as an empty identity.
|
|
34
|
+
*
|
|
35
|
+
* ## Read-only, and never a gate
|
|
36
|
+
*
|
|
37
|
+
* `whoami` mutates nothing and blocks nothing. It exits 0 whatever the rows
|
|
38
|
+
* say unless the caller asks for a non-zero exit on an unresolved row with
|
|
39
|
+
* `--strict`. A lexicon's implementation is held to the same rule: a self-query
|
|
40
|
+
* that reaches the substrate must be one the substrate treats as a read of the
|
|
41
|
+
* caller's own identity.
|
|
42
|
+
*
|
|
43
|
+
* ## No credential ever reaches the output
|
|
44
|
+
*
|
|
45
|
+
* An identity is a principal and a scope — an account id, a project, an org, a
|
|
46
|
+
* cluster context, a service-account name. It is never a token, a key, a
|
|
47
|
+
* password or a certificate. The rule is on the lexicon: report the fact, not
|
|
48
|
+
* the value, and where a substrate's only identity signal IS a secret, say so
|
|
49
|
+
* without it.
|
|
50
|
+
*
|
|
51
|
+
* {@link redactCredentialMaterial} is the backstop core applies to every field
|
|
52
|
+
* of every row regardless, not a license to be careless. It is deliberately
|
|
53
|
+
* structural rather than heuristic — it redacts what the process actually
|
|
54
|
+
* holds in a credential-shaped environment variable, plus the three literal
|
|
55
|
+
* shapes (PEM block, JWT, `Bearer …`) that no principal string can be — so an
|
|
56
|
+
* account id, an ARN and a `system:serviceaccount:…` subject survive it intact.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
import type { UnobservedReason } from "./observation";
|
|
60
|
+
|
|
61
|
+
/** The identity one lexicon resolved, and the scope it resolves to. */
|
|
62
|
+
export interface ResolvedIdentity {
|
|
63
|
+
/**
|
|
64
|
+
* The principal, as the substrate names it — an STS ARN, a
|
|
65
|
+
* `system:serviceaccount:<ns>:<name>` subject, a service-account email. Never
|
|
66
|
+
* a credential.
|
|
67
|
+
*/
|
|
68
|
+
identity: string;
|
|
69
|
+
/**
|
|
70
|
+
* What that principal is scoped to here — an account id plus region, a
|
|
71
|
+
* cluster context, a project, an org. The half that answers "acting on
|
|
72
|
+
* WHAT", which is where a wrong-account read is actually visible.
|
|
73
|
+
*/
|
|
74
|
+
scope: string;
|
|
75
|
+
/**
|
|
76
|
+
* Where the binding came from, in the project's own vocabulary:
|
|
77
|
+
* `k8s.profiles.prod.context`, `AWS_PROFILE`, `ADC`, `stacks[].region`. An
|
|
78
|
+
* identity with no provenance cannot be corrected by whoever reads it.
|
|
79
|
+
*/
|
|
80
|
+
source: string;
|
|
81
|
+
/**
|
|
82
|
+
* The resolved address the self-query was issued against. Must be the address
|
|
83
|
+
* this lexicon's live read resolves for the same environment — a whoami that
|
|
84
|
+
* reports a binding the read does not use is worse than no whoami.
|
|
85
|
+
*/
|
|
86
|
+
endpoint?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Why one lexicon could not resolve an identity. Total, and reason-typed. */
|
|
90
|
+
export interface UnresolvedIdentity {
|
|
91
|
+
/**
|
|
92
|
+
* Total verdict, from the same set an unobservable entity uses.
|
|
93
|
+
* `no-credentials` — nothing is configured to act as. `no-binding` — the
|
|
94
|
+
* environment resolves to no concrete target. `read-failed` — the substrate
|
|
95
|
+
* was reached and the self-query errored.
|
|
96
|
+
*/
|
|
97
|
+
reason: UnobservedReason;
|
|
98
|
+
/** Human-readable detail: the call that failed, the missing binding key. */
|
|
99
|
+
detail?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* What `describeIdentity()` may return: the resolved identity, or a typed
|
|
104
|
+
* refusal. Discriminated by the `unresolved` key, the same shape
|
|
105
|
+
* {@link import("./observation").EntityObservation} uses.
|
|
106
|
+
*/
|
|
107
|
+
export type DescribeIdentityResult = ResolvedIdentity | { unresolved: UnresolvedIdentity };
|
|
108
|
+
|
|
109
|
+
/** True when a lexicon answered with a typed refusal rather than an identity. */
|
|
110
|
+
export function isUnresolvedIdentity(
|
|
111
|
+
value: DescribeIdentityResult,
|
|
112
|
+
): value is { unresolved: UnresolvedIdentity } {
|
|
113
|
+
return typeof value === "object" && value !== null && "unresolved" in value;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The tri-state, as one row's verdict. */
|
|
117
|
+
export type IdentityStatus = "reported" | "unresolved" | "not-reported";
|
|
118
|
+
|
|
119
|
+
/** One lexicon's answer, normalized — the unit `whoami` renders and `--json` emits. */
|
|
120
|
+
export interface IdentityRow {
|
|
121
|
+
lexicon: string;
|
|
122
|
+
status: IdentityStatus;
|
|
123
|
+
/** REPORTED only. */
|
|
124
|
+
identity?: string;
|
|
125
|
+
/** REPORTED only. */
|
|
126
|
+
scope?: string;
|
|
127
|
+
/** REPORTED only. */
|
|
128
|
+
source?: string;
|
|
129
|
+
/** REPORTED only, and only when the lexicon named one. */
|
|
130
|
+
endpoint?: string;
|
|
131
|
+
/** UNRESOLVED only. */
|
|
132
|
+
reason?: UnobservedReason;
|
|
133
|
+
/** UNRESOLVED only, when the lexicon supplied one. */
|
|
134
|
+
detail?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** What core hands a lexicon's `describeIdentity`. */
|
|
138
|
+
export interface DescribeIdentityOptions {
|
|
139
|
+
environment: string;
|
|
140
|
+
/**
|
|
141
|
+
* The region the environment's stacks declare, when they declare exactly one
|
|
142
|
+
* (`stacks[].region`, #1261). Omitted when they declare none or disagree, in
|
|
143
|
+
* which case the lexicon resolves the region its own read path would.
|
|
144
|
+
*/
|
|
145
|
+
region?: string;
|
|
146
|
+
/** Project root whose `chant.config.ts` carries the binding. Defaults to cwd. */
|
|
147
|
+
cwd?: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The subset of a plugin this module needs — narrower than importing the whole contract. */
|
|
151
|
+
export interface IdentityPlugin {
|
|
152
|
+
name: string;
|
|
153
|
+
describeIdentity?(options: DescribeIdentityOptions): Promise<DescribeIdentityResult>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The marker a redacted field carries. Fixed text so a consumer can match it. */
|
|
157
|
+
export const REDACTED = "[redacted]";
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Environment variables whose VALUE is credential material. Matched on the
|
|
161
|
+
* name, so a lexicon that echoes one of these into an identity string has the
|
|
162
|
+
* value removed before it is printed or serialized.
|
|
163
|
+
*/
|
|
164
|
+
const CREDENTIAL_ENV_NAME =
|
|
165
|
+
/(SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|PRIVATE_KEY|APIKEY|API_KEY|ACCESS_KEY|SESSION_KEY|AUTH)/i;
|
|
166
|
+
|
|
167
|
+
/** Shortest env value worth redacting. Below this a "secret" is a false positive. */
|
|
168
|
+
const MIN_CREDENTIAL_LENGTH = 8;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Literal credential shapes. Each is something a principal string cannot be,
|
|
172
|
+
* so matching one is proof rather than a guess.
|
|
173
|
+
*/
|
|
174
|
+
const CREDENTIAL_SHAPES: RegExp[] = [
|
|
175
|
+
// A PEM block of any key type.
|
|
176
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
177
|
+
// A JWT: three base64url segments, the first starting with the `{"` header.
|
|
178
|
+
/\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]*/g,
|
|
179
|
+
// An `Authorization`-style scheme plus its value.
|
|
180
|
+
/\b(?:Bearer|Basic)\s+[A-Za-z0-9\-._~+/]{16,}={0,2}/g,
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Strip credential material from one reported field.
|
|
185
|
+
*
|
|
186
|
+
* Two rules, both structural. Any value this process holds in a
|
|
187
|
+
* credential-named environment variable is replaced wherever it appears, which
|
|
188
|
+
* covers the realistic accident — a lexicon echoing `$GITHUB_TOKEN` or
|
|
189
|
+
* `$AWS_SECRET_ACCESS_KEY` into a `source`. And the three literal shapes above
|
|
190
|
+
* are replaced on sight.
|
|
191
|
+
*
|
|
192
|
+
* Nothing is entropy-scored, so an ARN, an account id, a service-account email
|
|
193
|
+
* and a cluster context name pass through unchanged — which matters, because a
|
|
194
|
+
* mangled principal is a wrong answer, not a safe one.
|
|
195
|
+
*/
|
|
196
|
+
export function redactCredentialMaterial(
|
|
197
|
+
value: string,
|
|
198
|
+
env: Record<string, string | undefined> = process.env,
|
|
199
|
+
): string {
|
|
200
|
+
let out = value;
|
|
201
|
+
for (const [name, secret] of Object.entries(env)) {
|
|
202
|
+
if (!secret || secret.length < MIN_CREDENTIAL_LENGTH) continue;
|
|
203
|
+
if (!CREDENTIAL_ENV_NAME.test(name)) continue;
|
|
204
|
+
if (!out.includes(secret)) continue;
|
|
205
|
+
out = out.split(secret).join(REDACTED);
|
|
206
|
+
}
|
|
207
|
+
for (const shape of CREDENTIAL_SHAPES) out = out.replace(shape, REDACTED);
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Apply {@link redactCredentialMaterial} to every free-text field of a row. */
|
|
212
|
+
export function redactIdentityRow(
|
|
213
|
+
row: IdentityRow,
|
|
214
|
+
env: Record<string, string | undefined> = process.env,
|
|
215
|
+
): IdentityRow {
|
|
216
|
+
const clean = (v: string | undefined): string | undefined =>
|
|
217
|
+
v === undefined ? undefined : redactCredentialMaterial(v, env);
|
|
218
|
+
return {
|
|
219
|
+
...row,
|
|
220
|
+
...(row.identity !== undefined ? { identity: clean(row.identity) } : {}),
|
|
221
|
+
...(row.scope !== undefined ? { scope: clean(row.scope) } : {}),
|
|
222
|
+
...(row.source !== undefined ? { source: clean(row.source) } : {}),
|
|
223
|
+
...(row.endpoint !== undefined ? { endpoint: clean(row.endpoint) } : {}),
|
|
224
|
+
...(row.detail !== undefined ? { detail: clean(row.detail) } : {}),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Normalize one lexicon's answer into a row.
|
|
230
|
+
*
|
|
231
|
+
* Every degradation lands somewhere honest. No method at all is NOT REPORTED.
|
|
232
|
+
* A throw is UNRESOLVED with `read-failed` — the lexicon reached for the
|
|
233
|
+
* substrate and something broke, which is a different claim from having no
|
|
234
|
+
* credentials. An answer that carries no identity string is UNRESOLVED too: a
|
|
235
|
+
* lexicon cannot report an empty principal, because an empty principal renders
|
|
236
|
+
* as "acting as nobody", and nobody is a claim.
|
|
237
|
+
*/
|
|
238
|
+
export function identityRowFor(
|
|
239
|
+
lexicon: string,
|
|
240
|
+
result: DescribeIdentityResult | undefined,
|
|
241
|
+
env: Record<string, string | undefined> = process.env,
|
|
242
|
+
): IdentityRow {
|
|
243
|
+
if (result === undefined) return { lexicon, status: "not-reported" };
|
|
244
|
+
if (isUnresolvedIdentity(result)) {
|
|
245
|
+
return redactIdentityRow(
|
|
246
|
+
{
|
|
247
|
+
lexicon,
|
|
248
|
+
status: "unresolved",
|
|
249
|
+
reason: result.unresolved.reason,
|
|
250
|
+
...(result.unresolved.detail ? { detail: result.unresolved.detail } : {}),
|
|
251
|
+
},
|
|
252
|
+
env,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (!result.identity || result.identity.trim() === "") {
|
|
256
|
+
return {
|
|
257
|
+
lexicon,
|
|
258
|
+
status: "unresolved",
|
|
259
|
+
reason: "read-failed",
|
|
260
|
+
detail: "the lexicon reported an identity with no principal in it",
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
return redactIdentityRow(
|
|
264
|
+
{
|
|
265
|
+
lexicon,
|
|
266
|
+
status: "reported",
|
|
267
|
+
identity: result.identity,
|
|
268
|
+
scope: result.scope,
|
|
269
|
+
source: result.source,
|
|
270
|
+
...(result.endpoint ? { endpoint: result.endpoint } : {}),
|
|
271
|
+
},
|
|
272
|
+
env,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Ask every plugin who it would act as. One call per lexicon, concurrently,
|
|
278
|
+
* in the order the plugins were configured so the report is stable.
|
|
279
|
+
*
|
|
280
|
+
* A plugin that throws never fails the run: the throw becomes that lexicon's
|
|
281
|
+
* UNRESOLVED row. Reporting is the whole point, and a report that aborts on
|
|
282
|
+
* the first broken substrate is exactly the report nobody can use.
|
|
283
|
+
*/
|
|
284
|
+
export async function describeIdentities(
|
|
285
|
+
plugins: readonly IdentityPlugin[],
|
|
286
|
+
options: DescribeIdentityOptions,
|
|
287
|
+
env: Record<string, string | undefined> = process.env,
|
|
288
|
+
): Promise<IdentityRow[]> {
|
|
289
|
+
return Promise.all(
|
|
290
|
+
plugins.map(async (plugin): Promise<IdentityRow> => {
|
|
291
|
+
if (!plugin.describeIdentity) return { lexicon: plugin.name, status: "not-reported" };
|
|
292
|
+
try {
|
|
293
|
+
return identityRowFor(plugin.name, await plugin.describeIdentity(options), env);
|
|
294
|
+
} catch (err) {
|
|
295
|
+
return identityRowFor(
|
|
296
|
+
plugin.name,
|
|
297
|
+
{
|
|
298
|
+
unresolved: {
|
|
299
|
+
reason: "read-failed",
|
|
300
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
env,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
}),
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* A row's verdict as one short cell, for the IDENTITY column. Deliberately
|
|
312
|
+
* without the `detail`, which is a sentence and belongs under the table rather
|
|
313
|
+
* than inside a column every other row has to be padded to.
|
|
314
|
+
*/
|
|
315
|
+
export function identityStatusText(row: IdentityRow): string {
|
|
316
|
+
switch (row.status) {
|
|
317
|
+
case "reported":
|
|
318
|
+
return row.identity ?? "";
|
|
319
|
+
case "unresolved":
|
|
320
|
+
return `could not determine — ${unresolvedReasonText(row.reason)}`;
|
|
321
|
+
case "not-reported":
|
|
322
|
+
return "not reported — this lexicon does not answer for an identity";
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Why an identity did not resolve, in words. Deliberately distinct from
|
|
328
|
+
* `unobservedReasonText`: `no-credentials` here means "no identity is
|
|
329
|
+
* configured for this substrate", which is an answer, not a failure to look.
|
|
330
|
+
*/
|
|
331
|
+
export function unresolvedReasonText(reason: UnobservedReason | undefined): string {
|
|
332
|
+
switch (reason) {
|
|
333
|
+
case "no-credentials":
|
|
334
|
+
return "no identity is configured for this substrate";
|
|
335
|
+
case "no-binding":
|
|
336
|
+
return "this environment resolves to no target";
|
|
337
|
+
case "read-failed":
|
|
338
|
+
return "the substrate was reached and the self-query failed";
|
|
339
|
+
case "unsupported-kind":
|
|
340
|
+
return "the substrate exposes no self-query";
|
|
341
|
+
case "filtered":
|
|
342
|
+
return "withheld";
|
|
343
|
+
default:
|
|
344
|
+
return "no reason given";
|
|
345
|
+
}
|
|
346
|
+
}
|