@headless-lms/adapter-storage-s3 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mdwt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # @headless-lms/adapter-storage-s3
2
+
3
+ AWS S3 implementation of the `ObjectStorage` port from `@headless-lms/core/types`.
4
+ The bucket is private and must already exist; access is via short-lived presigned
5
+ URLs only. Also works against S3-compatible stores via `endpoint` + `forcePathStyle`.
6
+
7
+ The adapter reads no environment itself — the installation's `config.ts` parses
8
+ env into an `S3StorageConfig` and injects the constructed adapter:
9
+
10
+ ```ts
11
+ import { S3StorageAdapter } from "@headless-lms/adapter-storage-s3";
12
+
13
+ const container = await createContainer(config, {
14
+ adapters: { storage: new S3StorageAdapter(storageConfig) },
15
+ });
16
+ ```
17
+
18
+ Omit `accessKeyId`/`secretAccessKey` to use the default AWS credential chain
19
+ (IAM role, env vars, shared profile).
20
+
21
+ ## Bucket setup
22
+
23
+ The adapter never creates buckets — bucket creation needs `s3:CreateBucket`
24
+ permissions and region constraints the app credentials should not have. Create
25
+ the bucket once (console, CLI, or IaC); everything the app does is object-level.
26
+
27
+ 1. Create a bucket and keep Block Public Access on (the default). No bucket
28
+ policy is needed — access is via presigned URLs only.
29
+ 2. Grant the app credentials `s3:PutObject`, `s3:GetObject`, `s3:HeadObject`,
30
+ and `s3:DeleteObject` on `arn:aws:s3:::your-bucket/*`. Either an IAM user
31
+ with access keys, or an IAM role when running on AWS (then omit the keys).
32
+ 3. Add a CORS rule — browsers upload directly to the presigned URL:
33
+
34
+ ```json
35
+ [
36
+ {
37
+ "AllowedOrigins": ["https://your-app.com"],
38
+ "AllowedMethods": ["PUT", "GET"],
39
+ "AllowedHeaders": ["Content-Type"],
40
+ "MaxAgeSeconds": 3000
41
+ }
42
+ ]
43
+ ```
44
+
45
+ 4. Set `STORAGE_REGION`, `STORAGE_BUCKET`, and optionally the keys. Leave
46
+ `STORAGE_ENDPOINT` and `STORAGE_FORCE_PATH_STYLE` unset for real AWS.
47
+
48
+ ## Path style
49
+
50
+ S3 has two URL formats: virtual-hosted style (`https://bucket.s3.amazonaws.com/key`,
51
+ the AWS default) and path style (`https://s3.amazonaws.com/bucket/key`).
52
+ S3-compatible stores typically run on one hostname with no per-bucket DNS, so
53
+ the bucket name cannot be a subdomain — they need path style. Set
54
+ `STORAGE_FORCE_PATH_STYLE=true` only in that case.
55
+
56
+ ## Environment variables (reference installation)
57
+
58
+ | Variable | Required | Maps to | Notes |
59
+ | -------------------------- | -------- | ----------------------- | ------------------------------------- |
60
+ | `STORAGE_REGION` | yes | `region` | |
61
+ | `STORAGE_BUCKET` | yes | `bucket` | Must exist; kept private. |
62
+ | `STORAGE_ACCESS_KEY` | no | `accessKeyId` | Omit for the default credential chain.|
63
+ | `STORAGE_SECRET_KEY` | no | `secretAccessKey` | Omit for the default credential chain.|
64
+ | `STORAGE_ENDPOINT` | no | `endpoint` | Full URL, for S3-compatible stores. |
65
+ | `STORAGE_FORCE_PATH_STYLE` | no | `forcePathStyle` | `"true"` / `"false"`. |
66
+ | `STORAGE_UPLOAD_EXPIRY` | no | `uploadExpirySeconds` | Default 300. |
67
+ | `STORAGE_DOWNLOAD_EXPIRY` | no | `downloadExpirySeconds` | Default 86400. |
@@ -0,0 +1,34 @@
1
+ import { Logger, ObjectStorage, PresignDownloadInput, PresignedUpload, StoredObjectInfo } from "@headless-lms/core/types";
2
+ //#region src/index.d.ts
3
+ interface S3StorageConfig {
4
+ region: string;
5
+ bucket: string;
6
+ /** Omit both keys to use the default AWS credential chain (IAM role, env, profile). */
7
+ accessKeyId?: string;
8
+ secretAccessKey?: string;
9
+ /** Custom endpoint for S3-compatible stores. */
10
+ endpoint?: string;
11
+ /** Required by most S3-compatible stores when `endpoint` is set. */
12
+ forcePathStyle?: boolean;
13
+ uploadExpirySeconds: number;
14
+ downloadExpirySeconds: number;
15
+ }
16
+ declare class S3StorageAdapter implements ObjectStorage {
17
+ private readonly logger?;
18
+ private readonly client;
19
+ private readonly bucket;
20
+ private readonly uploadExpiry;
21
+ private readonly downloadExpiry;
22
+ constructor(config: S3StorageConfig, logger?: Logger | undefined);
23
+ presignUpload(input: {
24
+ key: string;
25
+ contentType?: string;
26
+ expiresInSeconds?: number;
27
+ }): Promise<PresignedUpload>;
28
+ presignDownload(input: PresignDownloadInput): Promise<string>;
29
+ stat(key: string): Promise<StoredObjectInfo | null>;
30
+ remove(key: string): Promise<void>;
31
+ }
32
+ //#endregion
33
+ export { S3StorageAdapter, S3StorageConfig };
34
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,80 @@
1
+ import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
2
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
3
+ //#region src/index.ts
4
+ var S3StorageAdapter = class {
5
+ logger;
6
+ client;
7
+ bucket;
8
+ uploadExpiry;
9
+ downloadExpiry;
10
+ constructor(config, logger) {
11
+ this.logger = logger;
12
+ this.client = new S3Client({
13
+ region: config.region,
14
+ ...config.endpoint ? { endpoint: config.endpoint } : {},
15
+ ...config.forcePathStyle !== void 0 ? { forcePathStyle: config.forcePathStyle } : {},
16
+ ...config.accessKeyId && config.secretAccessKey ? { credentials: {
17
+ accessKeyId: config.accessKeyId,
18
+ secretAccessKey: config.secretAccessKey
19
+ } } : {}
20
+ });
21
+ this.bucket = config.bucket;
22
+ this.uploadExpiry = config.uploadExpirySeconds;
23
+ this.downloadExpiry = config.downloadExpirySeconds;
24
+ }
25
+ async presignUpload(input) {
26
+ const expiresInSeconds = input.expiresInSeconds ?? this.uploadExpiry;
27
+ const command = new PutObjectCommand({
28
+ Bucket: this.bucket,
29
+ Key: input.key,
30
+ ...input.contentType ? { ContentType: input.contentType } : {}
31
+ });
32
+ return {
33
+ url: await getSignedUrl(this.client, command, { expiresIn: expiresInSeconds }),
34
+ method: "PUT",
35
+ key: input.key,
36
+ expiresInSeconds,
37
+ headers: input.contentType ? { "Content-Type": input.contentType } : {}
38
+ };
39
+ }
40
+ async presignDownload(input) {
41
+ const expiresInSeconds = input.expiresInSeconds ?? this.downloadExpiry;
42
+ const command = new GetObjectCommand({
43
+ Bucket: this.bucket,
44
+ Key: input.key,
45
+ ...input.downloadFilename ? { ResponseContentDisposition: `attachment; filename="${input.downloadFilename.replace(/"/g, "")}"` } : {}
46
+ });
47
+ return getSignedUrl(this.client, command, { expiresIn: expiresInSeconds });
48
+ }
49
+ async stat(key) {
50
+ try {
51
+ const s = await this.client.send(new HeadObjectCommand({
52
+ Bucket: this.bucket,
53
+ Key: key
54
+ }));
55
+ return {
56
+ key,
57
+ size: s.ContentLength ?? 0,
58
+ ...s.ContentType ? { contentType: s.ContentType } : {},
59
+ ...s.LastModified ? { lastModified: s.LastModified.toISOString() } : {}
60
+ };
61
+ } catch (err) {
62
+ if (isNotFound(err)) return null;
63
+ throw err;
64
+ }
65
+ }
66
+ async remove(key) {
67
+ await this.client.send(new DeleteObjectCommand({
68
+ Bucket: this.bucket,
69
+ Key: key
70
+ }));
71
+ }
72
+ };
73
+ function isNotFound(err) {
74
+ const e = err;
75
+ return e?.name === "NotFound" || e?.name === "NoSuchKey" || e?.$metadata?.httpStatusCode === 404;
76
+ }
77
+ //#endregion
78
+ export { S3StorageAdapter };
79
+
80
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["// AWS S3 implementation of the ObjectStorage port. Private bucket; access via\n// short-lived presigned URLs only. The bucket must already exist.\nimport {\n DeleteObjectCommand,\n GetObjectCommand,\n HeadObjectCommand,\n PutObjectCommand,\n S3Client,\n} from \"@aws-sdk/client-s3\";\nimport { getSignedUrl } from \"@aws-sdk/s3-request-presigner\";\nimport type {\n Logger,\n ObjectStorage,\n PresignDownloadInput,\n PresignedUpload,\n StoredObjectInfo,\n} from \"@headless-lms/core/types\";\n\nexport interface S3StorageConfig {\n region: string;\n bucket: string;\n /** Omit both keys to use the default AWS credential chain (IAM role, env, profile). */\n accessKeyId?: string;\n secretAccessKey?: string;\n /** Custom endpoint for S3-compatible stores. */\n endpoint?: string;\n /** Required by most S3-compatible stores when `endpoint` is set. */\n forcePathStyle?: boolean;\n uploadExpirySeconds: number;\n downloadExpirySeconds: number;\n}\n\nexport class S3StorageAdapter implements ObjectStorage {\n private readonly client: S3Client;\n private readonly bucket: string;\n private readonly uploadExpiry: number;\n private readonly downloadExpiry: number;\n\n constructor(\n config: S3StorageConfig,\n private readonly logger?: Logger,\n ) {\n this.client = new S3Client({\n region: config.region,\n ...(config.endpoint ? { endpoint: config.endpoint } : {}),\n ...(config.forcePathStyle !== undefined ? { forcePathStyle: config.forcePathStyle } : {}),\n ...(config.accessKeyId && config.secretAccessKey\n ? {\n credentials: {\n accessKeyId: config.accessKeyId,\n secretAccessKey: config.secretAccessKey,\n },\n }\n : {}),\n });\n this.bucket = config.bucket;\n this.uploadExpiry = config.uploadExpirySeconds;\n this.downloadExpiry = config.downloadExpirySeconds;\n }\n\n async presignUpload(input: {\n key: string;\n contentType?: string;\n expiresInSeconds?: number;\n }): Promise<PresignedUpload> {\n const expiresInSeconds = input.expiresInSeconds ?? this.uploadExpiry;\n const command = new PutObjectCommand({\n Bucket: this.bucket,\n Key: input.key,\n ...(input.contentType ? { ContentType: input.contentType } : {}),\n });\n const url = await getSignedUrl(this.client, command, { expiresIn: expiresInSeconds });\n return {\n url,\n method: \"PUT\",\n key: input.key,\n expiresInSeconds,\n headers: input.contentType ? { \"Content-Type\": input.contentType } : {},\n };\n }\n\n async presignDownload(input: PresignDownloadInput): Promise<string> {\n const expiresInSeconds = input.expiresInSeconds ?? this.downloadExpiry;\n const command = new GetObjectCommand({\n Bucket: this.bucket,\n Key: input.key,\n ...(input.downloadFilename\n ? {\n ResponseContentDisposition: `attachment; filename=\"${input.downloadFilename.replace(/\"/g, \"\")}\"`,\n }\n : {}),\n });\n return getSignedUrl(this.client, command, { expiresIn: expiresInSeconds });\n }\n\n async stat(key: string): Promise<StoredObjectInfo | null> {\n try {\n const s = await this.client.send(\n new HeadObjectCommand({ Bucket: this.bucket, Key: key }),\n );\n return {\n key,\n size: s.ContentLength ?? 0,\n ...(s.ContentType ? { contentType: s.ContentType } : {}),\n ...(s.LastModified ? { lastModified: s.LastModified.toISOString() } : {}),\n };\n } catch (err) {\n if (isNotFound(err)) {\n return null;\n }\n throw err;\n }\n }\n\n async remove(key: string): Promise<void> {\n await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));\n }\n}\n\nfunction isNotFound(err: unknown): boolean {\n const e = err as { name?: string; $metadata?: { httpStatusCode?: number } };\n return e?.name === \"NotFound\" || e?.name === \"NoSuchKey\" || e?.$metadata?.httpStatusCode === 404;\n}\n"],"mappings":";;;AAgCA,IAAa,mBAAb,MAAuD;CAQlC;CAPnB;CACA;CACA;CACA;CAEA,YACE,QACA,QACA;EADiB,KAAA,SAAA;EAEjB,KAAK,SAAS,IAAI,SAAS;GACzB,QAAQ,OAAO;GACf,GAAI,OAAO,WAAW,EAAE,UAAU,OAAO,SAAS,IAAI,CAAC;GACvD,GAAI,OAAO,mBAAmB,KAAA,IAAY,EAAE,gBAAgB,OAAO,eAAe,IAAI,CAAC;GACvF,GAAI,OAAO,eAAe,OAAO,kBAC7B,EACE,aAAa;IACX,aAAa,OAAO;IACpB,iBAAiB,OAAO;GAC1B,EACF,IACA,CAAC;EACP,CAAC;EACD,KAAK,SAAS,OAAO;EACrB,KAAK,eAAe,OAAO;EAC3B,KAAK,iBAAiB,OAAO;CAC/B;CAEA,MAAM,cAAc,OAIS;EAC3B,MAAM,mBAAmB,MAAM,oBAAoB,KAAK;EACxD,MAAM,UAAU,IAAI,iBAAiB;GACnC,QAAQ,KAAK;GACb,KAAK,MAAM;GACX,GAAI,MAAM,cAAc,EAAE,aAAa,MAAM,YAAY,IAAI,CAAC;EAChE,CAAC;EAED,OAAO;GACL,KAAA,MAFgB,aAAa,KAAK,QAAQ,SAAS,EAAE,WAAW,iBAAiB,CAAC;GAGlF,QAAQ;GACR,KAAK,MAAM;GACX;GACA,SAAS,MAAM,cAAc,EAAE,gBAAgB,MAAM,YAAY,IAAI,CAAC;EACxE;CACF;CAEA,MAAM,gBAAgB,OAA8C;EAClE,MAAM,mBAAmB,MAAM,oBAAoB,KAAK;EACxD,MAAM,UAAU,IAAI,iBAAiB;GACnC,QAAQ,KAAK;GACb,KAAK,MAAM;GACX,GAAI,MAAM,mBACN,EACE,4BAA4B,yBAAyB,MAAM,iBAAiB,QAAQ,MAAM,EAAE,EAAE,GAChG,IACA,CAAC;EACP,CAAC;EACD,OAAO,aAAa,KAAK,QAAQ,SAAS,EAAE,WAAW,iBAAiB,CAAC;CAC3E;CAEA,MAAM,KAAK,KAA+C;EACxD,IAAI;GACF,MAAM,IAAI,MAAM,KAAK,OAAO,KAC1B,IAAI,kBAAkB;IAAE,QAAQ,KAAK;IAAQ,KAAK;GAAI,CAAC,CACzD;GACA,OAAO;IACL;IACA,MAAM,EAAE,iBAAiB;IACzB,GAAI,EAAE,cAAc,EAAE,aAAa,EAAE,YAAY,IAAI,CAAC;IACtD,GAAI,EAAE,eAAe,EAAE,cAAc,EAAE,aAAa,YAAY,EAAE,IAAI,CAAC;GACzE;EACF,SAAS,KAAK;GACZ,IAAI,WAAW,GAAG,GAChB,OAAO;GAET,MAAM;EACR;CACF;CAEA,MAAM,OAAO,KAA4B;EACvC,MAAM,KAAK,OAAO,KAAK,IAAI,oBAAoB;GAAE,QAAQ,KAAK;GAAQ,KAAK;EAAI,CAAC,CAAC;CACnF;AACF;AAEA,SAAS,WAAW,KAAuB;CACzC,MAAM,IAAI;CACV,OAAO,GAAG,SAAS,cAAc,GAAG,SAAS,eAAe,GAAG,WAAW,mBAAmB;AAC/F"}
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@headless-lms/adapter-storage-s3",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "dependencies": {
14
+ "@aws-sdk/client-s3": "^3.712.0",
15
+ "@aws-sdk/s3-request-presigner": "^3.712.0",
16
+ "@headless-lms/core": "0.1.0"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^22.10.2",
20
+ "tsdown": "^0.22.9",
21
+ "typescript": "^5.7.2"
22
+ },
23
+ "description": "AWS S3 storage adapter for the headless LMS",
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/mdwt/headless-lms.git",
28
+ "directory": "adapters/storage-s3"
29
+ },
30
+ "homepage": "https://github.com/mdwt/headless-lms#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/mdwt/headless-lms/issues"
33
+ },
34
+ "files": [
35
+ "dist"
36
+ ],
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "scripts": {
41
+ "build": "tsdown",
42
+ "typecheck": "tsc --noEmit"
43
+ }
44
+ }