@checkstack/secrets-backend-local 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/drizzle/0000_dusty_sandman.sql +10 -0
- package/drizzle/0001_promote_gitops_secrets.sql +20 -0
- package/drizzle/meta/0000_snapshot.json +84 -0
- package/drizzle/meta/0001_snapshot.json +84 -0
- package/drizzle/meta/_journal.json +20 -0
- package/drizzle.config.ts +7 -0
- package/package.json +47 -0
- package/src/index.ts +47 -0
- package/src/plugin-metadata.ts +5 -0
- package/src/schema.ts +20 -0
- package/src/store.test.ts +56 -0
- package/src/store.ts +90 -0
- package/tsconfig.json +32 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# @checkstack/secrets-backend-local
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 270ef29: Add the Secrets platform (Phase 1): a central, plugin-agnostic secret manager with a pluggable backend extension point, a cross-plugin resolver service, and a universal Jenkins-style masking layer.
|
|
8
|
+
|
|
9
|
+
- New packages: `secrets-common` (schemas, contract, `secrets.read`/`secrets.manage`, masking utils), `secrets-backend` (`SecretBackend` extension point, `secretResolverRef`/`secretAdminRef` services, run-scoped masking context, RPC router), `secrets-backend-local` (default AES-256-GCM backend, owns the `secrets` table promoted from gitops), `secrets-frontend` (admin Settings page).
|
|
10
|
+
- Resolution machinery (`resolveSecretsBySchema`, `SecretStore`, `${{ secrets.NAME }}` / `x-secret`) is promoted out of `gitops-backend` into `secrets-backend`. GitOps now resolves and manages secrets through the platform's service refs (single source of truth); its secret table is migrated without loss.
|
|
11
|
+
- Universal masking seam wired at the central script-output boundaries: automation `run_script` / `run_shell` artifacts and the in-UI test panel redact run-scoped secret values from `result`/`stdout`/`stderr`/`error` before persist/return. Phase 1 resolves no run-scoped secrets yet, so masking is a no-op until Phase 2; the seam guarantees the boundary exists.
|
|
12
|
+
- No endpoint returns a secret value to a browser: DTOs expose only name/metadata/`hasValue`.
|
|
13
|
+
|
|
14
|
+
BREAKING CHANGES: `gitops-backend` now depends on `secrets-backend` and resolves/manages secrets through it. The `secrets` table is owned by `secrets-backend-local`; the gitops `secrets` table is retained as a migration source but is no longer the source of truth.
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- Updated dependencies [270ef29]
|
|
19
|
+
- Updated dependencies [270ef29]
|
|
20
|
+
- Updated dependencies [270ef29]
|
|
21
|
+
- Updated dependencies [b995afb]
|
|
22
|
+
- Updated dependencies [270ef29]
|
|
23
|
+
- Updated dependencies [270ef29]
|
|
24
|
+
- Updated dependencies [270ef29]
|
|
25
|
+
- Updated dependencies [b995afb]
|
|
26
|
+
- Updated dependencies [270ef29]
|
|
27
|
+
- Updated dependencies [270ef29]
|
|
28
|
+
- Updated dependencies [270ef29]
|
|
29
|
+
- Updated dependencies [270ef29]
|
|
30
|
+
- Updated dependencies [270ef29]
|
|
31
|
+
- Updated dependencies [270ef29]
|
|
32
|
+
- Updated dependencies [b995afb]
|
|
33
|
+
- @checkstack/backend-api@0.19.0
|
|
34
|
+
- @checkstack/secrets-backend@0.1.0
|
|
35
|
+
- @checkstack/secrets-common@0.1.0
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
CREATE TABLE "secrets" (
|
|
2
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
3
|
+
"name" text NOT NULL,
|
|
4
|
+
"encrypted_value" text NOT NULL,
|
|
5
|
+
"description" text,
|
|
6
|
+
"created_by" text,
|
|
7
|
+
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
8
|
+
"updated_at" timestamp DEFAULT now() NOT NULL,
|
|
9
|
+
CONSTRAINT "secrets_name_unique" UNIQUE("name")
|
|
10
|
+
);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- Promote existing GitOps secrets into the central local secret backend.
|
|
2
|
+
--
|
|
3
|
+
-- The legacy GitOps `secrets` table lives in the `plugin_gitops` Postgres
|
|
4
|
+
-- schema (see getPluginSchemaName). This copies its rows into this
|
|
5
|
+
-- backend's `secrets` table WITHOUT loss: it is guarded by to_regclass so a
|
|
6
|
+
-- fresh install (no gitops table) is a no-op, and ON CONFLICT (name) keeps
|
|
7
|
+
-- any already-present row. The source table is intentionally NOT dropped —
|
|
8
|
+
-- gitops switches to reading via the secretResolverRef, and leaving its
|
|
9
|
+
-- table in place means no rows can be lost if plugin migration order ever
|
|
10
|
+
-- runs gitops after this one.
|
|
11
|
+
DO $$
|
|
12
|
+
BEGIN
|
|
13
|
+
IF to_regclass('"plugin_gitops".secrets') IS NOT NULL THEN
|
|
14
|
+
INSERT INTO secrets (id, name, encrypted_value, description, created_by, created_at, updated_at)
|
|
15
|
+
SELECT id, name, encrypted_value, description, created_by, created_at, updated_at
|
|
16
|
+
FROM "plugin_gitops".secrets
|
|
17
|
+
ON CONFLICT (name) DO NOTHING;
|
|
18
|
+
END IF;
|
|
19
|
+
END
|
|
20
|
+
$$;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "262e0256-7c2f-4736-a72a-0e7e00d9d057",
|
|
3
|
+
"prevId": "00000000-0000-0000-0000-000000000000",
|
|
4
|
+
"version": "7",
|
|
5
|
+
"dialect": "postgresql",
|
|
6
|
+
"tables": {
|
|
7
|
+
"public.secrets": {
|
|
8
|
+
"name": "secrets",
|
|
9
|
+
"schema": "",
|
|
10
|
+
"columns": {
|
|
11
|
+
"id": {
|
|
12
|
+
"name": "id",
|
|
13
|
+
"type": "text",
|
|
14
|
+
"primaryKey": true,
|
|
15
|
+
"notNull": true
|
|
16
|
+
},
|
|
17
|
+
"name": {
|
|
18
|
+
"name": "name",
|
|
19
|
+
"type": "text",
|
|
20
|
+
"primaryKey": false,
|
|
21
|
+
"notNull": true
|
|
22
|
+
},
|
|
23
|
+
"encrypted_value": {
|
|
24
|
+
"name": "encrypted_value",
|
|
25
|
+
"type": "text",
|
|
26
|
+
"primaryKey": false,
|
|
27
|
+
"notNull": true
|
|
28
|
+
},
|
|
29
|
+
"description": {
|
|
30
|
+
"name": "description",
|
|
31
|
+
"type": "text",
|
|
32
|
+
"primaryKey": false,
|
|
33
|
+
"notNull": false
|
|
34
|
+
},
|
|
35
|
+
"created_by": {
|
|
36
|
+
"name": "created_by",
|
|
37
|
+
"type": "text",
|
|
38
|
+
"primaryKey": false,
|
|
39
|
+
"notNull": false
|
|
40
|
+
},
|
|
41
|
+
"created_at": {
|
|
42
|
+
"name": "created_at",
|
|
43
|
+
"type": "timestamp",
|
|
44
|
+
"primaryKey": false,
|
|
45
|
+
"notNull": true,
|
|
46
|
+
"default": "now()"
|
|
47
|
+
},
|
|
48
|
+
"updated_at": {
|
|
49
|
+
"name": "updated_at",
|
|
50
|
+
"type": "timestamp",
|
|
51
|
+
"primaryKey": false,
|
|
52
|
+
"notNull": true,
|
|
53
|
+
"default": "now()"
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"indexes": {},
|
|
57
|
+
"foreignKeys": {},
|
|
58
|
+
"compositePrimaryKeys": {},
|
|
59
|
+
"uniqueConstraints": {
|
|
60
|
+
"secrets_name_unique": {
|
|
61
|
+
"name": "secrets_name_unique",
|
|
62
|
+
"nullsNotDistinct": false,
|
|
63
|
+
"columns": [
|
|
64
|
+
"name"
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"policies": {},
|
|
69
|
+
"checkConstraints": {},
|
|
70
|
+
"isRLSEnabled": false
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"enums": {},
|
|
74
|
+
"schemas": {},
|
|
75
|
+
"sequences": {},
|
|
76
|
+
"roles": {},
|
|
77
|
+
"policies": {},
|
|
78
|
+
"views": {},
|
|
79
|
+
"_meta": {
|
|
80
|
+
"columns": {},
|
|
81
|
+
"schemas": {},
|
|
82
|
+
"tables": {}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "f2d2e3ed-c3e7-488b-b34b-1cacfcf43ac0",
|
|
3
|
+
"prevId": "262e0256-7c2f-4736-a72a-0e7e00d9d057",
|
|
4
|
+
"version": "7",
|
|
5
|
+
"dialect": "postgresql",
|
|
6
|
+
"tables": {
|
|
7
|
+
"public.secrets": {
|
|
8
|
+
"name": "secrets",
|
|
9
|
+
"schema": "",
|
|
10
|
+
"columns": {
|
|
11
|
+
"id": {
|
|
12
|
+
"name": "id",
|
|
13
|
+
"type": "text",
|
|
14
|
+
"primaryKey": true,
|
|
15
|
+
"notNull": true
|
|
16
|
+
},
|
|
17
|
+
"name": {
|
|
18
|
+
"name": "name",
|
|
19
|
+
"type": "text",
|
|
20
|
+
"primaryKey": false,
|
|
21
|
+
"notNull": true
|
|
22
|
+
},
|
|
23
|
+
"encrypted_value": {
|
|
24
|
+
"name": "encrypted_value",
|
|
25
|
+
"type": "text",
|
|
26
|
+
"primaryKey": false,
|
|
27
|
+
"notNull": true
|
|
28
|
+
},
|
|
29
|
+
"description": {
|
|
30
|
+
"name": "description",
|
|
31
|
+
"type": "text",
|
|
32
|
+
"primaryKey": false,
|
|
33
|
+
"notNull": false
|
|
34
|
+
},
|
|
35
|
+
"created_by": {
|
|
36
|
+
"name": "created_by",
|
|
37
|
+
"type": "text",
|
|
38
|
+
"primaryKey": false,
|
|
39
|
+
"notNull": false
|
|
40
|
+
},
|
|
41
|
+
"created_at": {
|
|
42
|
+
"name": "created_at",
|
|
43
|
+
"type": "timestamp",
|
|
44
|
+
"primaryKey": false,
|
|
45
|
+
"notNull": true,
|
|
46
|
+
"default": "now()"
|
|
47
|
+
},
|
|
48
|
+
"updated_at": {
|
|
49
|
+
"name": "updated_at",
|
|
50
|
+
"type": "timestamp",
|
|
51
|
+
"primaryKey": false,
|
|
52
|
+
"notNull": true,
|
|
53
|
+
"default": "now()"
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"indexes": {},
|
|
57
|
+
"foreignKeys": {},
|
|
58
|
+
"compositePrimaryKeys": {},
|
|
59
|
+
"uniqueConstraints": {
|
|
60
|
+
"secrets_name_unique": {
|
|
61
|
+
"name": "secrets_name_unique",
|
|
62
|
+
"nullsNotDistinct": false,
|
|
63
|
+
"columns": [
|
|
64
|
+
"name"
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"policies": {},
|
|
69
|
+
"checkConstraints": {},
|
|
70
|
+
"isRLSEnabled": false
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"enums": {},
|
|
74
|
+
"schemas": {},
|
|
75
|
+
"sequences": {},
|
|
76
|
+
"roles": {},
|
|
77
|
+
"policies": {},
|
|
78
|
+
"views": {},
|
|
79
|
+
"_meta": {
|
|
80
|
+
"columns": {},
|
|
81
|
+
"schemas": {},
|
|
82
|
+
"tables": {}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "7",
|
|
3
|
+
"dialect": "postgresql",
|
|
4
|
+
"entries": [
|
|
5
|
+
{
|
|
6
|
+
"idx": 0,
|
|
7
|
+
"version": "7",
|
|
8
|
+
"when": 1780163271755,
|
|
9
|
+
"tag": "0000_dusty_sandman",
|
|
10
|
+
"breakpoints": true
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"idx": 1,
|
|
14
|
+
"version": "7",
|
|
15
|
+
"when": 1780163349834,
|
|
16
|
+
"tag": "0001_promote_gitops_secrets",
|
|
17
|
+
"breakpoints": true
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/secrets-backend-local",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Default local secret backend: AES-256-GCM values in Postgres",
|
|
5
|
+
"author": "Checkstack contributors",
|
|
6
|
+
"license": "Elastic-2.0",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./src/index.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"checkstack": {
|
|
15
|
+
"type": "backend",
|
|
16
|
+
"pluginId": "secrets-backend-local"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"dev": "checkstack-dev",
|
|
20
|
+
"pack": "bunx @checkstack/scripts plugin-pack",
|
|
21
|
+
"typecheck": "tsgo -b",
|
|
22
|
+
"generate": "drizzle-kit generate",
|
|
23
|
+
"lint": "bun run lint:code",
|
|
24
|
+
"lint:code": "eslint . --max-warnings 0",
|
|
25
|
+
"test": "bun test"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@checkstack/backend-api": "0.18.0",
|
|
29
|
+
"@checkstack/common": "0.12.0",
|
|
30
|
+
"@checkstack/secrets-common": "0.0.1",
|
|
31
|
+
"@checkstack/secrets-backend": "0.0.1",
|
|
32
|
+
"drizzle-orm": "^0.45.0",
|
|
33
|
+
"uuid": "^14.0.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@checkstack/scripts": "0.3.4",
|
|
37
|
+
"@checkstack/dev-server": "2.0.0",
|
|
38
|
+
"@checkstack/backend": "0.11.0",
|
|
39
|
+
"@checkstack/tsconfig": "0.0.7",
|
|
40
|
+
"@checkstack/drizzle-helper": "0.0.5",
|
|
41
|
+
"@checkstack/test-utils-backend": "0.1.31",
|
|
42
|
+
"@types/bun": "^1.3.5",
|
|
43
|
+
"@types/node": "^20.0.0",
|
|
44
|
+
"drizzle-kit": "^0.31.10",
|
|
45
|
+
"typescript": "^5.7.2"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
|
|
2
|
+
import { secretBackendExtensionPoint } from "@checkstack/secrets-backend";
|
|
3
|
+
import { pluginMetadata } from "./plugin-metadata";
|
|
4
|
+
import {
|
|
5
|
+
createLocalSecretBackend,
|
|
6
|
+
LOCAL_SECRET_BACKEND_ID,
|
|
7
|
+
} from "./store";
|
|
8
|
+
import * as schema from "./schema";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Default secret backend: AES-256-GCM values in Postgres. Always
|
|
12
|
+
* available; the active backend when no external backend (Vault) is
|
|
13
|
+
* configured.
|
|
14
|
+
*
|
|
15
|
+
* Owns the `secrets` table (promoted from gitops — see ./drizzle). The
|
|
16
|
+
* backend is built in `init()` (where `database` is injected) and
|
|
17
|
+
* registered with the host's `secretBackendExtensionPoint`. The host runs
|
|
18
|
+
* its `register()` first (dep ordering), so the extension point exists by
|
|
19
|
+
* the time this init runs.
|
|
20
|
+
*/
|
|
21
|
+
export default createBackendPlugin({
|
|
22
|
+
metadata: pluginMetadata,
|
|
23
|
+
|
|
24
|
+
register(env) {
|
|
25
|
+
env.registerInit({
|
|
26
|
+
schema,
|
|
27
|
+
deps: {
|
|
28
|
+
logger: coreServices.logger,
|
|
29
|
+
},
|
|
30
|
+
init: async ({ logger, database }) => {
|
|
31
|
+
const backend = createLocalSecretBackend({ db: database });
|
|
32
|
+
env
|
|
33
|
+
.getExtensionPoint(secretBackendExtensionPoint)
|
|
34
|
+
.registerSecretBackend(backend, pluginMetadata);
|
|
35
|
+
logger.debug(
|
|
36
|
+
`🔐 Registered "${LOCAL_SECRET_BACKEND_ID}" secret backend.`,
|
|
37
|
+
);
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export {
|
|
44
|
+
createLocalSecretBackend,
|
|
45
|
+
LOCAL_SECRET_BACKEND_ID,
|
|
46
|
+
} from "./store";
|
|
47
|
+
export * as schema from "./schema";
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Local secret store: AES-256-GCM encrypted values keyed by unique name.
|
|
5
|
+
* Promoted from the GitOps `secrets` table — the gitops migration copies
|
|
6
|
+
* existing rows into this table (see ./drizzle migrations).
|
|
7
|
+
*
|
|
8
|
+
* NEVER expose `encryptedValue` or a decrypted value through any RPC/DTO.
|
|
9
|
+
*/
|
|
10
|
+
export const secrets = pgTable("secrets", {
|
|
11
|
+
id: text("id").primaryKey(),
|
|
12
|
+
/** Unique name referenced via `${{ secrets.NAME }}`. */
|
|
13
|
+
name: text("name").notNull().unique(),
|
|
14
|
+
/** AES-256-GCM encrypted value (iv:authTag:ciphertext). */
|
|
15
|
+
encryptedValue: text("encrypted_value").notNull(),
|
|
16
|
+
description: text("description"),
|
|
17
|
+
createdBy: text("created_by"),
|
|
18
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
19
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
20
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// AES-256 needs a 32-byte (64 hex char) key. Set before importing the
|
|
2
|
+
// encryption module so module-eval-time encrypt() calls succeed.
|
|
3
|
+
process.env.ENCRYPTION_MASTER_KEY = "11".repeat(32);
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from "bun:test";
|
|
6
|
+
import { encrypt, decrypt } from "@checkstack/backend-api";
|
|
7
|
+
import { toSecretMetadata, LOCAL_SECRET_BACKEND_ID } from "./store";
|
|
8
|
+
import type * as schema from "./schema";
|
|
9
|
+
|
|
10
|
+
describe("local backend encryption round-trip", () => {
|
|
11
|
+
it("encrypts then decrypts a value back to the original", () => {
|
|
12
|
+
const value = "gh_aBcDeF123456-with-special/chars=&";
|
|
13
|
+
const stored = encrypt(value);
|
|
14
|
+
expect(stored).not.toContain(value);
|
|
15
|
+
expect(decrypt(stored)).toBe(value);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("produces a different ciphertext per encrypt (random IV)", () => {
|
|
19
|
+
const a = encrypt("same-plaintext-value");
|
|
20
|
+
const b = encrypt("same-plaintext-value");
|
|
21
|
+
expect(a).not.toBe(b);
|
|
22
|
+
expect(decrypt(a)).toBe("same-plaintext-value");
|
|
23
|
+
expect(decrypt(b)).toBe("same-plaintext-value");
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("toSecretMetadata (no-value-leak guarantee)", () => {
|
|
28
|
+
const baseRow: typeof schema.secrets.$inferSelect = {
|
|
29
|
+
id: "id-1",
|
|
30
|
+
name: "api_key",
|
|
31
|
+
encryptedValue: encrypt("supersecretvalue"),
|
|
32
|
+
description: "An API key",
|
|
33
|
+
createdBy: "user-1",
|
|
34
|
+
createdAt: new Date("2026-01-01T00:00:00Z"),
|
|
35
|
+
updatedAt: new Date("2026-01-02T00:00:00Z"),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
it("exposes metadata + hasValue but never the value", () => {
|
|
39
|
+
const meta = toSecretMetadata(baseRow);
|
|
40
|
+
expect(meta.name).toBe("api_key");
|
|
41
|
+
expect(meta.hasValue).toBe(true);
|
|
42
|
+
expect(meta.backend).toBe(LOCAL_SECRET_BACKEND_ID);
|
|
43
|
+
// The serialized DTO must not contain the value or the ciphertext.
|
|
44
|
+
const serialized = JSON.stringify(meta);
|
|
45
|
+
expect(serialized).not.toContain("supersecretvalue");
|
|
46
|
+
expect(serialized).not.toContain(baseRow.encryptedValue);
|
|
47
|
+
// The metadata shape has no `value` / `encryptedValue` keys.
|
|
48
|
+
expect("value" in meta).toBe(false);
|
|
49
|
+
expect("encryptedValue" in meta).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("reports hasValue=false for an empty stored value", () => {
|
|
53
|
+
const meta = toSecretMetadata({ ...baseRow, encryptedValue: "" });
|
|
54
|
+
expect(meta.hasValue).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { encrypt, decrypt, type SafeDatabase } from "@checkstack/backend-api";
|
|
2
|
+
import type { SecretBackend } from "@checkstack/secrets-backend";
|
|
3
|
+
import type { SecretMetadata } from "@checkstack/secrets-common";
|
|
4
|
+
import { eq } from "drizzle-orm";
|
|
5
|
+
import { v4 as uuidv4 } from "uuid";
|
|
6
|
+
import * as schema from "./schema";
|
|
7
|
+
|
|
8
|
+
/** Stable id for the default local backend. */
|
|
9
|
+
export const LOCAL_SECRET_BACKEND_ID = "local";
|
|
10
|
+
|
|
11
|
+
type Db = SafeDatabase<typeof schema>;
|
|
12
|
+
|
|
13
|
+
type SecretRow = typeof schema.secrets.$inferSelect;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Map a stored row to its public metadata. Pure + exported so the
|
|
17
|
+
* no-value-leak guarantee is unit-testable: the result carries `hasValue`
|
|
18
|
+
* but never the encrypted/decrypted value.
|
|
19
|
+
*/
|
|
20
|
+
export function toSecretMetadata(row: SecretRow): SecretMetadata {
|
|
21
|
+
return {
|
|
22
|
+
id: row.id,
|
|
23
|
+
name: row.name,
|
|
24
|
+
description: row.description,
|
|
25
|
+
hasValue: row.encryptedValue.length > 0,
|
|
26
|
+
backend: LOCAL_SECRET_BACKEND_ID,
|
|
27
|
+
createdBy: row.createdBy,
|
|
28
|
+
createdAt: row.createdAt,
|
|
29
|
+
updatedAt: row.updatedAt,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build the default local {@link SecretBackend}: AES-256-GCM values in the
|
|
35
|
+
* `secrets` table. `set`/`delete` are supported (read-write); `get`
|
|
36
|
+
* decrypts a single value; `list` returns metadata only (never values).
|
|
37
|
+
*/
|
|
38
|
+
export function createLocalSecretBackend({ db }: { db: Db }): SecretBackend {
|
|
39
|
+
return {
|
|
40
|
+
id: LOCAL_SECRET_BACKEND_ID,
|
|
41
|
+
|
|
42
|
+
async get({ name }) {
|
|
43
|
+
const rows = await db
|
|
44
|
+
.select()
|
|
45
|
+
.from(schema.secrets)
|
|
46
|
+
.where(eq(schema.secrets.name, name));
|
|
47
|
+
const row = rows[0];
|
|
48
|
+
if (!row) return;
|
|
49
|
+
return decrypt(row.encryptedValue);
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
async set({ name, value, description, createdBy }) {
|
|
53
|
+
const encryptedValue = encrypt(value);
|
|
54
|
+
const existing = await db
|
|
55
|
+
.select()
|
|
56
|
+
.from(schema.secrets)
|
|
57
|
+
.where(eq(schema.secrets.name, name));
|
|
58
|
+
|
|
59
|
+
if (existing[0]) {
|
|
60
|
+
await db
|
|
61
|
+
.update(schema.secrets)
|
|
62
|
+
.set({
|
|
63
|
+
encryptedValue,
|
|
64
|
+
// Only overwrite description when explicitly provided.
|
|
65
|
+
...(description === undefined ? {} : { description }),
|
|
66
|
+
updatedAt: new Date(),
|
|
67
|
+
})
|
|
68
|
+
.where(eq(schema.secrets.name, name));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await db.insert(schema.secrets).values({
|
|
73
|
+
id: uuidv4(),
|
|
74
|
+
name,
|
|
75
|
+
encryptedValue,
|
|
76
|
+
description: description ?? null,
|
|
77
|
+
createdBy: createdBy ?? null,
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
async delete({ name }) {
|
|
82
|
+
await db.delete(schema.secrets).where(eq(schema.secrets.name, name));
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
async list(): Promise<SecretMetadata[]> {
|
|
86
|
+
const rows = await db.select().from(schema.secrets);
|
|
87
|
+
return rows.map((row) => toSecretMetadata(row));
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "@checkstack/tsconfig/backend.json",
|
|
3
|
+
"include": [
|
|
4
|
+
"src"
|
|
5
|
+
],
|
|
6
|
+
"references": [
|
|
7
|
+
{
|
|
8
|
+
"path": "../backend"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"path": "../backend-api"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"path": "../common"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"path": "../dev-server"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"path": "../drizzle-helper"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"path": "../secrets-backend"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"path": "../secrets-common"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"path": "../test-utils-backend"
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|