@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 +44 -0
- package/package.json +28 -0
- package/src/config.test.ts +45 -0
- package/src/config.ts +77 -0
- package/src/index.ts +47 -0
- package/src/key.test.ts +20 -0
- package/src/key.ts +24 -0
- package/src/plugin-metadata.ts +5 -0
- package/src/store.ts +73 -0
- package/tsconfig.json +17 -0
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";
|
package/src/key.test.ts
ADDED
|
@@ -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;
|
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