@checkstack/script-packages-store-s3 0.2.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 ADDED
@@ -0,0 +1,44 @@
1
+ # @checkstack/script-packages-store-s3
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 270ef29: Add the two built-in blob-store plugins for script packages.
8
+
9
+ - `script-packages-store-postgres` (default, zero extra infra): persists
10
+ content-addressed blobs in Postgres as base64 `text` (following the
11
+ existing repo convention for binary columns), registered as the
12
+ `postgres` backend via `blobStoreExtensionPoint`.
13
+ - `script-packages-store-s3` (preferred when configured): S3-compatible
14
+ store backed by Bun's native `S3Client` (no AWS SDK). Config from env
15
+ (`endpoint`, `bucket`, `region`, `accessKeyId`, `secretAccessKey`,
16
+ `forcePathStyle`); credentials never touch the DB. Registers the `s3`
17
+ backend only when configured, and reports a partial-config error
18
+ instead of silently falling back.
19
+
20
+ ### Patch Changes
21
+
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 [b995afb]
31
+ - Updated dependencies [b995afb]
32
+ - Updated dependencies [270ef29]
33
+ - Updated dependencies [270ef29]
34
+ - Updated dependencies [270ef29]
35
+ - Updated dependencies [b995afb]
36
+ - Updated dependencies [270ef29]
37
+ - Updated dependencies [b995afb]
38
+ - Updated dependencies [270ef29]
39
+ - Updated dependencies [270ef29]
40
+ - Updated dependencies [270ef29]
41
+ - Updated dependencies [270ef29]
42
+ - Updated dependencies [270ef29]
43
+ - @checkstack/backend-api@0.19.0
44
+ - @checkstack/script-packages-backend@0.2.0
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@checkstack/script-packages-store-s3",
3
+ "version": "0.2.0",
4
+ "license": "Elastic-2.0",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "checkstack": {
8
+ "type": "backend"
9
+ },
10
+ "scripts": {
11
+ "typecheck": "tsgo -b",
12
+ "lint": "bun run lint:code",
13
+ "lint:code": "eslint . --max-warnings 0",
14
+ "test": "bun test"
15
+ },
16
+ "dependencies": {
17
+ "@checkstack/backend-api": "0.18.0",
18
+ "@checkstack/common": "0.12.0",
19
+ "@checkstack/script-packages-backend": "0.1.0",
20
+ "zod": "^4.0.0"
21
+ },
22
+ "devDependencies": {
23
+ "@checkstack/scripts": "0.3.4",
24
+ "@checkstack/tsconfig": "0.0.7",
25
+ "@types/bun": "^1.0.0",
26
+ "typescript": "^5.7.2"
27
+ }
28
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { loadS3ConfigFromEnv } from "./config";
3
+
4
+ const full = {
5
+ CHECKSTACK_SCRIPT_PACKAGES_S3_ENDPOINT: "https://s3.example.com",
6
+ CHECKSTACK_SCRIPT_PACKAGES_S3_BUCKET: "checkstack",
7
+ CHECKSTACK_SCRIPT_PACKAGES_S3_REGION: "us-east-1",
8
+ CHECKSTACK_SCRIPT_PACKAGES_S3_ACCESS_KEY_ID: "AKIA",
9
+ CHECKSTACK_SCRIPT_PACKAGES_S3_SECRET_ACCESS_KEY: "secret",
10
+ CHECKSTACK_SCRIPT_PACKAGES_S3_FORCE_PATH_STYLE: "true",
11
+ };
12
+
13
+ describe("loadS3ConfigFromEnv", () => {
14
+ test("returns unconfigured when nothing is set", () => {
15
+ expect(loadS3ConfigFromEnv({})).toEqual({ ok: "unconfigured" });
16
+ });
17
+
18
+ test("parses a complete config", () => {
19
+ const res = loadS3ConfigFromEnv(full);
20
+ expect(res.ok).toBe(true);
21
+ if (res.ok !== true) throw new Error("expected ok");
22
+ expect(res.config.bucket).toBe("checkstack");
23
+ expect(res.config.forcePathStyle).toBe(true);
24
+ expect(res.config.endpoint).toBe("https://s3.example.com");
25
+ });
26
+
27
+ test("reports an error for a partial config instead of silently ignoring", () => {
28
+ const res = loadS3ConfigFromEnv({
29
+ CHECKSTACK_SCRIPT_PACKAGES_S3_BUCKET: "checkstack",
30
+ // missing credentials
31
+ });
32
+ expect(res.ok).toBe(false);
33
+ if (res.ok !== false) throw new Error("expected error");
34
+ expect(res.error).toMatch(/accessKeyId|secretAccessKey/);
35
+ });
36
+
37
+ test("forcePathStyle defaults to false when only other fields set", () => {
38
+ const { CHECKSTACK_SCRIPT_PACKAGES_S3_FORCE_PATH_STYLE: _omit, ...rest } =
39
+ full;
40
+ const res = loadS3ConfigFromEnv(rest);
41
+ expect(res.ok).toBe(true);
42
+ if (res.ok !== true) throw new Error("expected ok");
43
+ expect(res.config.forcePathStyle).toBe(false);
44
+ });
45
+ });
package/src/config.ts ADDED
@@ -0,0 +1,77 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * S3-compatible blob-store configuration.
5
+ *
6
+ * Credentials are object-store secrets and come from the environment (the
7
+ * standard for S3 creds) rather than the application DB - we never persist
8
+ * `secretAccessKey` in plaintext. The admin UI persists only the
9
+ * active-backend *selection* (`script_package_storage_config`); the
10
+ * connection details live in env so secret material stays out of the DB.
11
+ */
12
+ export const S3StoreConfigSchema = z.object({
13
+ endpoint: z.string().url().optional(),
14
+ bucket: z.string().min(1),
15
+ region: z.string().optional(),
16
+ accessKeyId: z.string().min(1),
17
+ secretAccessKey: z.string().min(1),
18
+ /** Path-style addressing (needed by MinIO / non-AWS S3). */
19
+ forcePathStyle: z.boolean().default(false),
20
+ });
21
+ export type S3StoreConfig = z.infer<typeof S3StoreConfigSchema>;
22
+
23
+ function parseBool(value: string | undefined): boolean | undefined {
24
+ if (value === undefined) return undefined;
25
+ return value === "true" || value === "1";
26
+ }
27
+
28
+ /**
29
+ * Load + validate the S3 config from env. Returns `undefined` when the
30
+ * backend isn't configured (no bucket / creds), so the platform falls back
31
+ * to the postgres default. Returns a `{ error }` when partially configured
32
+ * so the admin can see what's missing instead of silently falling back.
33
+ */
34
+ export function loadS3ConfigFromEnv(
35
+ env: NodeJS.ProcessEnv = process.env,
36
+ ):
37
+ | { ok: true; config: S3StoreConfig }
38
+ | { ok: false; error: string }
39
+ | { ok: "unconfigured" } {
40
+ const raw = {
41
+ endpoint: env.CHECKSTACK_SCRIPT_PACKAGES_S3_ENDPOINT,
42
+ bucket: env.CHECKSTACK_SCRIPT_PACKAGES_S3_BUCKET,
43
+ region: env.CHECKSTACK_SCRIPT_PACKAGES_S3_REGION,
44
+ accessKeyId: env.CHECKSTACK_SCRIPT_PACKAGES_S3_ACCESS_KEY_ID,
45
+ secretAccessKey: env.CHECKSTACK_SCRIPT_PACKAGES_S3_SECRET_ACCESS_KEY,
46
+ forcePathStyle: parseBool(
47
+ env.CHECKSTACK_SCRIPT_PACKAGES_S3_FORCE_PATH_STYLE,
48
+ ),
49
+ };
50
+
51
+ const anySet =
52
+ raw.endpoint ||
53
+ raw.bucket ||
54
+ raw.region ||
55
+ raw.accessKeyId ||
56
+ raw.secretAccessKey ||
57
+ raw.forcePathStyle !== undefined;
58
+ if (!anySet) {
59
+ return { ok: "unconfigured" };
60
+ }
61
+
62
+ const parsed = S3StoreConfigSchema.safeParse({
63
+ ...raw,
64
+ // Drop undefined so zod defaults / optionals apply cleanly.
65
+ forcePathStyle: raw.forcePathStyle ?? false,
66
+ });
67
+ if (!parsed.success) {
68
+ const missing = parsed.error.issues
69
+ .map((issue) => issue.path.join("."))
70
+ .join(", ");
71
+ return {
72
+ ok: false,
73
+ error: `S3 blob store is partially configured; invalid/missing: ${missing}`,
74
+ };
75
+ }
76
+ return { ok: true, config: parsed.data };
77
+ }
package/src/index.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
2
+ import { blobStoreExtensionPoint } from "@checkstack/script-packages-backend";
3
+ import { pluginMetadata } from "./plugin-metadata";
4
+ import { loadS3ConfigFromEnv } from "./config";
5
+ import { createS3BlobStore, S3_BLOB_STORE_ID } from "./store";
6
+
7
+ /**
8
+ * S3-compatible blob-store plugin. Registers an `s3` {@link BlobStore} when
9
+ * S3 env vars are configured; otherwise stays dormant so the platform
10
+ * falls back to the postgres default. A partially-configured S3 (bucket
11
+ * set but creds missing) logs an error rather than silently registering a
12
+ * broken store.
13
+ */
14
+ export default createBackendPlugin({
15
+ metadata: pluginMetadata,
16
+
17
+ register(env) {
18
+ env.registerInit({
19
+ deps: {
20
+ logger: coreServices.logger,
21
+ },
22
+ init: async ({ logger }) => {
23
+ const loaded = loadS3ConfigFromEnv();
24
+ if (loaded.ok === "unconfigured") {
25
+ logger.debug(
26
+ "📦 S3 script-package blob store not configured; skipping.",
27
+ );
28
+ return;
29
+ }
30
+ if (loaded.ok === false) {
31
+ logger.error(loaded.error);
32
+ return;
33
+ }
34
+ const store = createS3BlobStore(loaded.config);
35
+ env
36
+ .getExtensionPoint(blobStoreExtensionPoint)
37
+ .registerBlobStore(store, pluginMetadata);
38
+ logger.debug(
39
+ `📦 Registered "${S3_BLOB_STORE_ID}" script-package blob store (bucket "${loaded.config.bucket}").`,
40
+ );
41
+ },
42
+ });
43
+ },
44
+ });
45
+
46
+ export { createS3BlobStore, S3_BLOB_STORE_ID } from "./store";
47
+ export { loadS3ConfigFromEnv, type S3StoreConfig } from "./config";
@@ -0,0 +1,20 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { integrityToKey, keyToIntegrity, S3_KEY_PREFIX } from "./key";
3
+
4
+ describe("integrity <-> S3 key", () => {
5
+ test("round-trips an SRI integrity with special chars", () => {
6
+ const integrity = "sha512-AbC+/def==";
7
+ const key = integrityToKey(integrity);
8
+ expect(key.startsWith(S3_KEY_PREFIX)).toBe(true);
9
+ expect(key.slice(S3_KEY_PREFIX.length)).toMatch(/^[a-f0-9]+$/);
10
+ expect(keyToIntegrity(key)).toBe(integrity);
11
+ });
12
+
13
+ test("rejects keys outside the prefix", () => {
14
+ expect(keyToIntegrity("other/thing")).toBeUndefined();
15
+ });
16
+
17
+ test("rejects non-hex payloads", () => {
18
+ expect(keyToIntegrity(`${S3_KEY_PREFIX}zzz`)).toBeUndefined();
19
+ });
20
+ });
package/src/key.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Map a content-addressed integrity (SRI, e.g. `sha512-AbC+/d==`) to a
3
+ * safe, reversible S3 object key under a fixed prefix.
4
+ *
5
+ * SRI strings contain `+`, `/`, and `=`, which are legal-but-awkward in S3
6
+ * keys (and `/` would create spurious "folders"). We hex-encode the raw
7
+ * integrity bytes so the key is `[a-f0-9]` only, and prefix it so blobs are
8
+ * namespaced within a shared bucket.
9
+ */
10
+ const KEY_PREFIX = "script-packages/blobs/";
11
+
12
+ export function integrityToKey(integrity: string): string {
13
+ const hex = Buffer.from(integrity, "utf8").toString("hex");
14
+ return `${KEY_PREFIX}${hex}`;
15
+ }
16
+
17
+ export function keyToIntegrity(key: string): string | undefined {
18
+ if (!key.startsWith(KEY_PREFIX)) return undefined;
19
+ const hex = key.slice(KEY_PREFIX.length);
20
+ if (!/^[a-f0-9]*$/.test(hex) || hex.length % 2 !== 0) return undefined;
21
+ return Buffer.from(hex, "hex").toString("utf8");
22
+ }
23
+
24
+ export const S3_KEY_PREFIX = KEY_PREFIX;
@@ -0,0 +1,5 @@
1
+ import { definePluginMetadata } from "@checkstack/common";
2
+
3
+ export const pluginMetadata = definePluginMetadata({
4
+ pluginId: "script-packages-store-s3",
5
+ });
package/src/store.ts ADDED
@@ -0,0 +1,73 @@
1
+ import { S3Client } from "bun";
2
+ import type { BlobStore } from "@checkstack/script-packages-backend";
3
+ import type { S3StoreConfig } from "./config";
4
+ import { integrityToKey, keyToIntegrity, S3_KEY_PREFIX } from "./key";
5
+
6
+ export const S3_BLOB_STORE_ID = "s3";
7
+
8
+ interface S3ListResponse {
9
+ contents?: { key: string }[];
10
+ isTruncated?: boolean;
11
+ nextContinuationToken?: string;
12
+ }
13
+
14
+ /**
15
+ * S3-compatible {@link BlobStore} backed by Bun's native `S3Client` (no
16
+ * AWS SDK dependency). Content-addressed: blobs are immutable, so `put`
17
+ * unconditionally writes (overwriting with identical bytes is harmless).
18
+ *
19
+ * Credentials come from {@link S3StoreConfig} (loaded from env), never the
20
+ * application DB. `forcePathStyle` is honored via Bun's endpoint handling
21
+ * for MinIO / non-AWS S3.
22
+ */
23
+ export function createS3BlobStore(config: S3StoreConfig): BlobStore {
24
+ const client = new S3Client({
25
+ accessKeyId: config.accessKeyId,
26
+ secretAccessKey: config.secretAccessKey,
27
+ bucket: config.bucket,
28
+ ...(config.region ? { region: config.region } : {}),
29
+ ...(config.endpoint ? { endpoint: config.endpoint } : {}),
30
+ });
31
+
32
+ return {
33
+ id: S3_BLOB_STORE_ID,
34
+
35
+ async put({ integrity, bytes }) {
36
+ await client.write(integrityToKey(integrity), bytes);
37
+ },
38
+
39
+ async get({ integrity }) {
40
+ const file = client.file(integrityToKey(integrity));
41
+ if (!(await file.exists())) return;
42
+ const buf = await file.arrayBuffer();
43
+ return new Uint8Array(buf);
44
+ },
45
+
46
+ async has({ integrity }) {
47
+ return client.file(integrityToKey(integrity)).exists();
48
+ },
49
+
50
+ async delete({ integrity }) {
51
+ await client.delete(integrityToKey(integrity));
52
+ },
53
+
54
+ async list() {
55
+ const integrities: string[] = [];
56
+ let continuationToken: string | undefined;
57
+ do {
58
+ const res: S3ListResponse = await client.list({
59
+ prefix: S3_KEY_PREFIX,
60
+ ...(continuationToken ? { continuationToken } : {}),
61
+ });
62
+ for (const item of res.contents ?? []) {
63
+ const integrity = keyToIntegrity(item.key);
64
+ if (integrity !== undefined) integrities.push(integrity);
65
+ }
66
+ continuationToken = res.isTruncated
67
+ ? res.nextContinuationToken
68
+ : undefined;
69
+ } while (continuationToken);
70
+ return integrities;
71
+ },
72
+ };
73
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json",
3
+ "include": [
4
+ "src"
5
+ ],
6
+ "references": [
7
+ {
8
+ "path": "../backend-api"
9
+ },
10
+ {
11
+ "path": "../common"
12
+ },
13
+ {
14
+ "path": "../script-packages-backend"
15
+ }
16
+ ]
17
+ }