@checkstack/secrets-backend 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.
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { z } from "zod";
3
+ import { configString } from "@checkstack/backend-api";
4
+ import { createSecretResolverService } from "./resolver-service";
5
+ import type { SecretStore } from "./secret-resolver";
6
+
7
+ const store: SecretStore = {
8
+ resolve: async (name) => {
9
+ const values: Record<string, string> = {
10
+ jira_token: "jira-secret-value",
11
+ DB_PASS: "db-secret-value",
12
+ missing: "", // present-but-empty, distinct from absent
13
+ };
14
+ if (!(name in values)) throw new Error(`Secret not found: ${name}`);
15
+ return values[name];
16
+ },
17
+ };
18
+
19
+ describe("SecretResolverService.resolveBySchema", () => {
20
+ it("resolves ${{ secrets.NAME }} only in x-secret fields", async () => {
21
+ const service = createSecretResolverService({ secretStore: store });
22
+ const schema = z.object({
23
+ host: z.string(),
24
+ password: configString({ "x-secret": true }),
25
+ });
26
+ const { resolved, warnings } = await service.resolveBySchema({
27
+ value: { host: "h", password: "${{ secrets.DB_PASS }}" },
28
+ schema,
29
+ });
30
+ expect(resolved).toEqual({ host: "h", password: "db-secret-value" });
31
+ expect(warnings).toEqual([]);
32
+ });
33
+ });
34
+
35
+ describe("SecretResolverService.resolveForRun", () => {
36
+ it("resolves a least-privilege env allowlist + masking context", async () => {
37
+ const service = createSecretResolverService({ secretStore: store });
38
+ const { env, masking } = await service.resolveForRun({
39
+ secretEnv: {
40
+ API_TOKEN: "${{ secrets.jira_token }}",
41
+ DATABASE_PASSWORD: "${{ secrets.DB_PASS }}",
42
+ },
43
+ });
44
+ expect(env).toEqual({
45
+ API_TOKEN: "jira-secret-value",
46
+ DATABASE_PASSWORD: "db-secret-value",
47
+ });
48
+ expect(masking.size).toBe(2);
49
+ // The masking context redacts the resolved values out of output.
50
+ expect(masking.maskText("token=jira-secret-value")).toBe("token=****");
51
+ });
52
+
53
+ it("supports inline interpolation in a mapping value", async () => {
54
+ const service = createSecretResolverService({ secretStore: store });
55
+ const { env } = await service.resolveForRun({
56
+ secretEnv: { CONN: "user:${{ secrets.DB_PASS }}@host" },
57
+ });
58
+ expect(env.CONN).toBe("user:db-secret-value@host");
59
+ });
60
+
61
+ it("throws when a referenced secret cannot be resolved", async () => {
62
+ const service = createSecretResolverService({ secretStore: store });
63
+ await expect(
64
+ service.resolveForRun({ secretEnv: { X: "${{ secrets.absent }}" } }),
65
+ ).rejects.toThrow("Secret not found: absent");
66
+ });
67
+
68
+ it("resolves each distinct secret once even when reused", async () => {
69
+ let calls = 0;
70
+ const counting: SecretStore = {
71
+ resolve: async (name) => {
72
+ calls++;
73
+ return `val-${name}`;
74
+ },
75
+ };
76
+ const service = createSecretResolverService({ secretStore: counting });
77
+ await service.resolveForRun({
78
+ secretEnv: {
79
+ A: "${{ secrets.same }}",
80
+ B: "${{ secrets.same }}",
81
+ C: "${{ secrets.other }}",
82
+ },
83
+ });
84
+ // "same" + "other" → 2 distinct resolutions despite 3 references.
85
+ expect(calls).toBe(2);
86
+ });
87
+ });
@@ -0,0 +1,122 @@
1
+ import { z } from "zod";
2
+ import {
3
+ collectSecretNames,
4
+ normalizeSecretEnvValue,
5
+ type SecretEnvMapping,
6
+ } from "@checkstack/secrets-common";
7
+ import {
8
+ resolveSecretsBySchema,
9
+ type SecretResolutionResult,
10
+ type SecretStore,
11
+ } from "./secret-resolver";
12
+ import {
13
+ createMaskingContext,
14
+ type SecretMaskingContext,
15
+ } from "./masking-context";
16
+
17
+ /**
18
+ * Cross-plugin secret resolution service, exposed via `secretResolverRef`.
19
+ *
20
+ * Wraps the promoted `resolveSecretsBySchema` so any consumer plugin
21
+ * (gitops, automation, healthcheck) resolves `${{ secrets.NAME }}` in
22
+ * `x-secret` fields on demand against the active backend, and can resolve
23
+ * a run's least-privilege env-mapping allowlist into values + a masking
24
+ * context.
25
+ *
26
+ * NOTE: every method here returns secret VALUES. It is a service-typed,
27
+ * backend-to-backend interface only — it MUST NOT be exposed to a browser
28
+ * client. Output produced from these values must pass through the
29
+ * returned `SecretMaskingContext` before it is persisted or returned.
30
+ */
31
+ export interface SecretResolverService {
32
+ /** Resolve a single secret value by name. Throws if not found. */
33
+ resolveSecret(input: { name: string }): Promise<string>;
34
+
35
+ /**
36
+ * Resolve `${{ secrets.NAME }}` templates in `x-secret`-annotated fields
37
+ * of `value`, per `schema`. Promoted from gitops-backend.
38
+ */
39
+ resolveBySchema<T>(input: {
40
+ value: T;
41
+ schema: z.ZodTypeAny;
42
+ }): Promise<SecretResolutionResult<T>>;
43
+
44
+ /**
45
+ * Resolve a consumer's least-privilege secret→env allowlist into the
46
+ * concrete env vars for a run, plus a run-scoped masking context holding
47
+ * exactly those values. If any referenced secret is missing, this throws
48
+ * (the run must fail clearly rather than run without a required secret).
49
+ */
50
+ resolveForRun(input: {
51
+ secretEnv: SecretEnvMapping;
52
+ }): Promise<{ env: Record<string, string>; masking: SecretMaskingContext }>;
53
+ }
54
+
55
+ /**
56
+ * Build the resolver service over a {@link SecretStore} (typically backed
57
+ * by the active backend's `get`).
58
+ */
59
+ export function createSecretResolverService({
60
+ secretStore,
61
+ }: {
62
+ secretStore: SecretStore;
63
+ }): SecretResolverService {
64
+ return {
65
+ resolveSecret({ name }) {
66
+ return secretStore.resolve(name);
67
+ },
68
+
69
+ resolveBySchema({ value, schema }) {
70
+ return resolveSecretsBySchema({ value, schema, secretStore });
71
+ },
72
+
73
+ async resolveForRun({ secretEnv }) {
74
+ // Normalize tolerated bare secret names to the canonical template
75
+ // (the value schema no longer transforms — see `secretEnvValueSchema`),
76
+ // so collection + substitution below see only `${{ secrets.NAME }}`.
77
+ const normalized: Record<string, string> = {};
78
+ for (const [envName, value] of Object.entries(secretEnv)) {
79
+ normalized[envName] = normalizeSecretEnvValue(value);
80
+ }
81
+
82
+ // Collect every distinct secret name referenced by the mapping, so
83
+ // we resolve each once even if reused across multiple env vars.
84
+ const names = new Set(
85
+ collectSecretNames({ value: Object.values(normalized) }),
86
+ );
87
+
88
+ const resolved = new Map<string, string>();
89
+ for (const name of names) {
90
+ resolved.set(name, await secretStore.resolve(name));
91
+ }
92
+
93
+ // Build the env by substituting templates in each mapping value.
94
+ const env: Record<string, string> = {};
95
+ for (const [envName, template] of Object.entries(normalized)) {
96
+ env[envName] = substituteTemplate({ template, resolved });
97
+ }
98
+
99
+ const masking = createMaskingContext({ values: resolved.values() });
100
+ return { env, masking };
101
+ },
102
+ };
103
+ }
104
+
105
+ const TEMPLATE_RE = /\$\{\{\s*secrets\.([a-zA-Z0-9_-]+)\s*\}\}/g;
106
+
107
+ function substituteTemplate({
108
+ template,
109
+ resolved,
110
+ }: {
111
+ template: string;
112
+ resolved: Map<string, string>;
113
+ }): string {
114
+ TEMPLATE_RE.lastIndex = 0;
115
+ return template.replaceAll(TEMPLATE_RE, (_full, name: string) => {
116
+ const value = resolved.get(name);
117
+ if (value === undefined) {
118
+ throw new Error(`Required secret not available: ${name}`);
119
+ }
120
+ return value;
121
+ });
122
+ }
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { call } from "@orpc/server";
3
+ import { createMockRpcContext } from "@checkstack/backend-api";
4
+ import { createSecretsRouter } from "./router";
5
+ import { createSecretBackendRegistry } from "./secret-backend-registry";
6
+ import type { SecretBackend } from "./secret-backend";
7
+
8
+ const mockUser = {
9
+ type: "user" as const,
10
+ id: "test-user",
11
+ accessRules: ["*"],
12
+ roles: ["admin"],
13
+ };
14
+
15
+ /** Fully writable backend (local): implements both `set` and `delete`. */
16
+ function localBackend(): SecretBackend {
17
+ return {
18
+ id: "local",
19
+ get: async () => undefined,
20
+ set: async () => {},
21
+ delete: async () => {},
22
+ list: async () => [],
23
+ };
24
+ }
25
+
26
+ /** Read-through backend (vault): implements neither `set` nor `delete`. */
27
+ function vaultBackend(): SecretBackend {
28
+ return {
29
+ id: "vault",
30
+ get: async () => undefined,
31
+ list: async () => [],
32
+ };
33
+ }
34
+
35
+ function makeRouter(active: string, backends: SecretBackend[]) {
36
+ const registry = createSecretBackendRegistry();
37
+ for (const b of backends) registry.register(b);
38
+ return createSecretsRouter({
39
+ backends: registry,
40
+ getActiveBackendId: async () => active,
41
+ setActiveBackendId: async () => {},
42
+ emitChanged: async () => {},
43
+ });
44
+ }
45
+
46
+ describe("getBackendConfig — writable capability flag", () => {
47
+ it("reports writable: true when the local backend is active", async () => {
48
+ const router = makeRouter("local", [localBackend(), vaultBackend()]);
49
+ const context = createMockRpcContext({ user: mockUser });
50
+
51
+ const dto = await call(router.getBackendConfig, undefined, { context });
52
+
53
+ expect(dto.activeBackend).toBe("local");
54
+ expect(dto.writable).toBe(true);
55
+ });
56
+
57
+ it("reports writable: false when a read-through (Vault) backend is active", async () => {
58
+ const router = makeRouter("vault", [localBackend(), vaultBackend()]);
59
+ const context = createMockRpcContext({ user: mockUser });
60
+
61
+ const dto = await call(router.getBackendConfig, undefined, { context });
62
+
63
+ expect(dto.activeBackend).toBe("vault");
64
+ expect(dto.writable).toBe(false);
65
+ });
66
+
67
+ it("reports writable: false when the active backend is unresolved", async () => {
68
+ // Active id points at a backend that is not registered.
69
+ const router = makeRouter("missing", [localBackend()]);
70
+ const context = createMockRpcContext({ user: mockUser });
71
+
72
+ const dto = await call(router.getBackendConfig, undefined, { context });
73
+
74
+ expect(dto.writable).toBe(false);
75
+ });
76
+ });
package/src/router.ts ADDED
@@ -0,0 +1,167 @@
1
+ import { implement, ORPCError } from "@orpc/server";
2
+ import {
3
+ autoAuthMiddleware,
4
+ correlationMiddleware,
5
+ type Logger,
6
+ type RpcContext,
7
+ } from "@checkstack/backend-api";
8
+ import {
9
+ secretsContract,
10
+ type BackendConfigDto,
11
+ type SetBackendConfigInput,
12
+ } from "@checkstack/secrets-common";
13
+ import { extractErrorMessage } from "@checkstack/common";
14
+ import type { SecretBackendRegistry } from "./secret-backend-registry";
15
+ import { createSecretAdminService } from "./admin-service";
16
+ import { isBackendWritable } from "./secret-backend";
17
+
18
+ const os = implement(secretsContract)
19
+ .$context<RpcContext>()
20
+ .use(correlationMiddleware)
21
+ .use(autoAuthMiddleware);
22
+
23
+ export interface SecretsRouterDeps {
24
+ backends: SecretBackendRegistry;
25
+ /** Resolve the currently active backend id. */
26
+ getActiveBackendId: () => Promise<string>;
27
+ /** Persist the active backend id. */
28
+ setActiveBackendId: (id: string) => Promise<void>;
29
+ /** Notify consumers (e.g. gitops) that a secret changed. */
30
+ emitChanged: (input: {
31
+ name: string;
32
+ change: "created" | "rotated" | "deleted";
33
+ }) => Promise<void>;
34
+ /** Logger for the admin service's short-secret warning. */
35
+ logger?: Logger;
36
+ }
37
+
38
+ export function createSecretsRouter({
39
+ backends,
40
+ getActiveBackendId,
41
+ setActiveBackendId,
42
+ emitChanged,
43
+ logger,
44
+ }: SecretsRouterDeps) {
45
+ // The router shares the admin service so write semantics + change events
46
+ // stay identical whether a secret is managed via the central UI or via a
47
+ // consumer plugin (e.g. gitops) delegating to secretAdminRef.
48
+ const admin = createSecretAdminService({
49
+ getActiveBackend: async () => backends.get(await getActiveBackendId()),
50
+ onChanged: emitChanged,
51
+ logger,
52
+ });
53
+
54
+ const listSecrets = os.listSecrets.handler(async () => {
55
+ // Metadata only — never values.
56
+ return admin.list();
57
+ });
58
+
59
+ const listSecretNames = os.listSecretNames.handler(async () => {
60
+ const metadata = await admin.list();
61
+ return metadata.map((m) => m.name);
62
+ });
63
+
64
+ const setSecret = os.setSecret.handler(async ({ input, context }) => {
65
+ const actor = context.user;
66
+ const createdBy =
67
+ actor && actor.type !== "service" ? actor.id : undefined;
68
+
69
+ try {
70
+ await admin.setSecret({
71
+ name: input.name,
72
+ value: input.value,
73
+ description: input.description,
74
+ createdBy,
75
+ });
76
+ } catch (error) {
77
+ throw new ORPCError("NOT_IMPLEMENTED", {
78
+ message: extractErrorMessage(error),
79
+ });
80
+ }
81
+
82
+ const after = await admin.list();
83
+ const meta = after.find((m) => m.name === input.name);
84
+ return { id: meta?.id ?? input.name, name: input.name };
85
+ });
86
+
87
+ const deleteSecret = os.deleteSecret.handler(async ({ input }) => {
88
+ try {
89
+ await admin.deleteSecret({ name: input.name });
90
+ } catch (error) {
91
+ throw new ORPCError("NOT_IMPLEMENTED", {
92
+ message: extractErrorMessage(error),
93
+ });
94
+ }
95
+ return { success: true };
96
+ });
97
+
98
+ const getBackendConfig = os.getBackendConfig.handler(async () => {
99
+ const activeBackend = await getActiveBackendId();
100
+ // Surface the active backend's connection metadata (never credentials).
101
+ const active = backends.has(activeBackend)
102
+ ? backends.get(activeBackend)
103
+ : undefined;
104
+ const dto: BackendConfigDto = {
105
+ activeBackend,
106
+ availableBackends: backends.ids(),
107
+ // Capability flag the UI uses to hide write controls for read-through
108
+ // backends. False when the active backend is unresolved/read-only.
109
+ writable: active ? isBackendWritable({ backend: active }) : false,
110
+ };
111
+ if (active?.getConfigMeta) {
112
+ dto.vault = await active.getConfigMeta();
113
+ }
114
+ return dto;
115
+ });
116
+
117
+ const setBackendConfig = os.setBackendConfig.handler(async ({ input }) => {
118
+ // Configure the target backend (e.g. Vault address/auth/credential)
119
+ // BEFORE switching to it, so we never activate an unconfigured backend.
120
+ if (input.vault) {
121
+ const target = backends.has(input.activeBackend)
122
+ ? backends.get(input.activeBackend)
123
+ : undefined;
124
+ if (!target?.configure) {
125
+ throw new ORPCError("BAD_REQUEST", {
126
+ message: `Backend "${input.activeBackend}" is not configurable.`,
127
+ });
128
+ }
129
+ const vault: NonNullable<SetBackendConfigInput["vault"]> = input.vault;
130
+ await target.configure(vault);
131
+ }
132
+ if (!backends.has(input.activeBackend)) {
133
+ throw new ORPCError("BAD_REQUEST", {
134
+ message: `Backend "${input.activeBackend}" is not registered.`,
135
+ });
136
+ }
137
+ await setActiveBackendId(input.activeBackend);
138
+ return { success: true };
139
+ });
140
+
141
+ const testBackend = os.testBackend.handler(async ({ input }) => {
142
+ const id = input.backend ?? (await getActiveBackendId());
143
+ if (!backends.has(id)) {
144
+ return { ok: false, message: `Backend "${id}" is not registered.` };
145
+ }
146
+ const backend = backends.get(id);
147
+ // A backend with no external connectivity (e.g. local) is always ok.
148
+ if (!backend.test) {
149
+ return { ok: true, message: `Backend "${id}" requires no connectivity.` };
150
+ }
151
+ try {
152
+ return await backend.test();
153
+ } catch (error) {
154
+ return { ok: false, message: extractErrorMessage(error) };
155
+ }
156
+ });
157
+
158
+ return os.router({
159
+ listSecrets,
160
+ listSecretNames,
161
+ setSecret,
162
+ deleteSecret,
163
+ getBackendConfig,
164
+ setBackendConfig,
165
+ testBackend,
166
+ });
167
+ }
@@ -0,0 +1,45 @@
1
+ import type { SecretBackend } from "./secret-backend";
2
+
3
+ /**
4
+ * Collects every registered {@link SecretBackend} (one per backend
5
+ * plugin) and resolves the active one by id. Mirrors the script-packages
6
+ * blob-store registry.
7
+ */
8
+ export interface SecretBackendRegistry {
9
+ register(backend: SecretBackend): void;
10
+ /** All registered backend ids (for the admin backend selector). */
11
+ ids(): string[];
12
+ has(id: string): boolean;
13
+ /** Resolve a specific backend by id. Throws if not registered. */
14
+ get(id: string): SecretBackend;
15
+ }
16
+
17
+ export function createSecretBackendRegistry(): SecretBackendRegistry {
18
+ const backends = new Map<string, SecretBackend>();
19
+
20
+ return {
21
+ register(backend) {
22
+ backends.set(backend.id, backend);
23
+ },
24
+
25
+ ids() {
26
+ return [...backends.keys()];
27
+ },
28
+
29
+ has(id) {
30
+ return backends.has(id);
31
+ },
32
+
33
+ get(id) {
34
+ const backend = backends.get(id);
35
+ if (!backend) {
36
+ throw new Error(
37
+ `Secret backend "${id}" is not registered. Available: ${
38
+ [...backends.keys()].join(", ") || "(none)"
39
+ }`,
40
+ );
41
+ }
42
+ return backend;
43
+ },
44
+ };
45
+ }
@@ -0,0 +1,21 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { isBackendWritable } from "./secret-backend";
3
+
4
+ describe("isBackendWritable", () => {
5
+ it("is true only when BOTH set and delete are implemented", () => {
6
+ expect(
7
+ isBackendWritable({ backend: { set: async () => {}, delete: async () => {} } }),
8
+ ).toBe(true);
9
+ });
10
+
11
+ it("is false for a read-through backend (neither set nor delete)", () => {
12
+ expect(isBackendWritable({ backend: {} })).toBe(false);
13
+ });
14
+
15
+ it("is false when only one of set / delete is implemented", () => {
16
+ expect(isBackendWritable({ backend: { set: async () => {} } })).toBe(false);
17
+ expect(isBackendWritable({ backend: { delete: async () => {} } })).toBe(
18
+ false,
19
+ );
20
+ });
21
+ });
@@ -0,0 +1,94 @@
1
+ import { createExtensionPoint } from "@checkstack/backend-api";
2
+ import type { PluginMetadata } from "@checkstack/common";
3
+ import type {
4
+ SecretMetadata,
5
+ SetBackendConfigInput,
6
+ BackendConfigDto,
7
+ } from "@checkstack/secrets-common";
8
+
9
+ /** Public Vault connection metadata (never the credential). */
10
+ type VaultConfigMeta = NonNullable<BackendConfigDto["vault"]>;
11
+
12
+ /**
13
+ * A pluggable secret store. Built-ins: `secrets-backend-local` (default,
14
+ * AES-256-GCM in the `secrets` table) and `secrets-backend-vault` (Phase
15
+ * 4). The active backend is config-selected; local is the default when no
16
+ * external backend is configured.
17
+ *
18
+ * `list` returns metadata only and NEVER values. `get` resolves a single
19
+ * value and is service-internal — its result must not cross to a browser.
20
+ * Read-through backends (Vault) implement only `get`/`list`; the local
21
+ * backend additionally implements `set`/`delete`.
22
+ */
23
+ export interface SecretBackend {
24
+ /** Stable backend id recorded in `secrets.backend`. */
25
+ readonly id: string;
26
+
27
+ /** Resolve a single secret value by name, or undefined if absent. */
28
+ get(input: { name: string }): Promise<string | undefined>;
29
+
30
+ /** Create or rotate a secret value. Local backend only. */
31
+ set?(input: {
32
+ name: string;
33
+ value: string;
34
+ description?: string;
35
+ createdBy?: string;
36
+ }): Promise<void>;
37
+
38
+ /** Delete a secret by name. Local backend only. Idempotent. */
39
+ delete?(input: { name: string }): Promise<void>;
40
+
41
+ /** Metadata for every secret this backend holds. NEVER returns values. */
42
+ list(): Promise<SecretMetadata[]>;
43
+
44
+ /**
45
+ * Validate connectivity / auth for this backend. Returns status only and
46
+ * NEVER a secret value. Optional — a backend that needs no external
47
+ * connectivity (e.g. local) may omit it (treated as always-ok).
48
+ */
49
+ test?(): Promise<{ ok: boolean; message: string }>;
50
+
51
+ /**
52
+ * Persist this backend's connection config (e.g. Vault address / auth /
53
+ * credential). Implemented by externally-configured backends; the backend
54
+ * owns its own config storage (its plugin-scoped `ConfigService`). The
55
+ * write-only `credential` is encrypted at rest and never returned.
56
+ * Local-style backends omit this.
57
+ */
58
+ configure?(input: NonNullable<SetBackendConfigInput["vault"]>): Promise<void>;
59
+
60
+ /**
61
+ * Public connection metadata for the admin UI (NEVER the credential).
62
+ * Omitted by backends with no external connection.
63
+ */
64
+ getConfigMeta?(): Promise<VaultConfigMeta | undefined>;
65
+ }
66
+
67
+ /**
68
+ * Whether a backend accepts secret-value writes (create / rotate / delete)
69
+ * from the admin UI. True only when it implements BOTH `set` and `delete`;
70
+ * read-through backends (e.g. Vault) implement neither, so this is `false`
71
+ * and the UI hides its write controls. Capability boolean only — leaks
72
+ * nothing sensitive.
73
+ */
74
+ export function isBackendWritable({
75
+ backend,
76
+ }: {
77
+ backend: Pick<SecretBackend, "set" | "delete">;
78
+ }): boolean {
79
+ return typeof backend.set === "function" && typeof backend.delete === "function";
80
+ }
81
+
82
+ /**
83
+ * Extension point a secret-backend plugin registers its implementation
84
+ * with. The active backend is selected via config; the resolver resolves
85
+ * the registered backend by id.
86
+ */
87
+ export interface SecretBackendExtensionPoint {
88
+ registerSecretBackend(backend: SecretBackend, metadata: PluginMetadata): void;
89
+ }
90
+
91
+ export const secretBackendExtensionPoint =
92
+ createExtensionPoint<SecretBackendExtensionPoint>(
93
+ "secrets.secretBackendExtensionPoint",
94
+ );