diff --git a/.changeset/add-cloudfront.md b/.changeset/add-cloudfront.md new file mode 100644 index 00000000..8b5cf6f5 --- /dev/null +++ b/.changeset/add-cloudfront.md @@ -0,0 +1,5 @@ +--- +"@effect-aws/cloudfront": minor +--- + +Add the `@effect-aws/cloudfront` package with a Layer-based Effect wrapper around the AWS CloudFront signing helpers. diff --git a/.projenrc.ts b/.projenrc.ts index d367ad1f..2c81f43f 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -218,6 +218,15 @@ new TypeScriptLibProject({ peerDeps: ["effect@>=3.15.5 <4.0.0"], }); +new TypeScriptLibProject({ + parent: project, + name: "cloudfront", + description: "Effectful AWS CloudFront modules", + deps: ["@aws-sdk/cloudfront-signer@^3"], + devDeps: [...effectDeps], + peerDeps: ["effect@>=3.15.5 <4.0.0"], +}); + project.addGitIgnore("/.direnv"); // flake environment creates .direnv folder project.addGitIgnore("/docs"); // docs are generated project.addGitIgnore(".idea"); diff --git a/packages/cloudfront/.gitattributes b/packages/cloudfront/.gitattributes new file mode 100644 index 00000000..44ee1f36 --- /dev/null +++ b/packages/cloudfront/.gitattributes @@ -0,0 +1,21 @@ +# ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". + +* text=auto eol=lf +/.gitattributes linguist-generated +/.gitignore linguist-generated +/.npmignore linguist-generated +/.npmrc linguist-generated +/.projen/** linguist-generated +/.projen/deps.json linguist-generated +/.projen/files.json linguist-generated +/.projen/tasks.json linguist-generated +/docgen.json linguist-generated +/LICENSE linguist-generated +/package.json linguist-generated +/pnpm-lock.yaml linguist-generated +/tsconfig.cjs.json linguist-generated +/tsconfig.dev.json linguist-generated +/tsconfig.esm.json linguist-generated +/tsconfig.json linguist-generated +/tsconfig.src.json linguist-generated +/vitest.config.ts linguist-generated \ No newline at end of file diff --git a/packages/cloudfront/.gitignore b/packages/cloudfront/.gitignore new file mode 100644 index 00000000..9b3e7285 --- /dev/null +++ b/packages/cloudfront/.gitignore @@ -0,0 +1,44 @@ +# ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". +!/.gitattributes +!/.projen/tasks.json +!/.projen/deps.json +!/.projen/files.json +!/package.json +!/LICENSE +!/.npmignore +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json +pids +*.pid +*.seed +*.pid.lock +lib-cov +coverage +*.lcov +.nyc_output +build/Release +node_modules/ +jspm_packages/ +*.tsbuildinfo +.eslintcache +*.tgz +.yarn-integrity +.cache +!/.npmrc +!/test/ +!/tsconfig.json +!/src/ +/build +/dist/ +!/tsconfig.src.json +!/tsconfig.dev.json +!/tsconfig.esm.json +!/tsconfig.cjs.json +!/docgen.json +docs/ +!/vitest.config.ts diff --git a/packages/cloudfront/.npmignore b/packages/cloudfront/.npmignore new file mode 100644 index 00000000..fe4e41d6 --- /dev/null +++ b/packages/cloudfront/.npmignore @@ -0,0 +1,19 @@ +# ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". +/.projen/ +/test/ +/src/ +!/build/ +!/build/**/*.js +!/build/**/*.d.ts +dist +/tsconfig.json +/.github/ +/.vscode/ +/.idea/ +/.projenrc.js +tsconfig.tsbuildinfo +/tsconfig.src.json +/tsconfig.dev.json +/tsconfig.esm.json +/tsconfig.cjs.json +/.gitattributes diff --git a/packages/cloudfront/.projen/deps.json b/packages/cloudfront/.projen/deps.json new file mode 100644 index 00000000..db56617c --- /dev/null +++ b/packages/cloudfront/.projen/deps.json @@ -0,0 +1,29 @@ +{ + "dependencies": [ + { + "name": "@types/node", + "version": "ts5.4", + "type": "build" + }, + { + "name": "effect", + "type": "build" + }, + { + "name": "typescript", + "version": "^5.4.2", + "type": "build" + }, + { + "name": "effect", + "version": ">=3.15.5 <4.0.0", + "type": "peer" + }, + { + "name": "@aws-sdk/cloudfront-signer", + "version": "^3", + "type": "runtime" + } + ], + "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." +} diff --git a/packages/cloudfront/.projen/files.json b/packages/cloudfront/.projen/files.json new file mode 100644 index 00000000..e57ad5f8 --- /dev/null +++ b/packages/cloudfront/.projen/files.json @@ -0,0 +1,19 @@ +{ + "files": [ + ".gitattributes", + ".gitignore", + ".npmignore", + ".projen/deps.json", + ".projen/files.json", + ".projen/tasks.json", + "docgen.json", + "LICENSE", + "tsconfig.cjs.json", + "tsconfig.dev.json", + "tsconfig.esm.json", + "tsconfig.json", + "tsconfig.src.json", + "vitest.config.ts" + ], + "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." +} diff --git a/packages/cloudfront/.projen/tasks.json b/packages/cloudfront/.projen/tasks.json new file mode 100644 index 00000000..ad983f13 --- /dev/null +++ b/packages/cloudfront/.projen/tasks.json @@ -0,0 +1,120 @@ +{ + "tasks": { + "build": { + "name": "build", + "description": "Full release build", + "steps": [ + { + "spawn": "pre-compile" + }, + { + "spawn": "compile" + }, + { + "spawn": "post-compile" + }, + { + "spawn": "test" + }, + { + "spawn": "package" + } + ] + }, + "compile": { + "name": "compile", + "description": "Only compile", + "steps": [ + { + "exec": "tsc -b ./tsconfig.cjs.json ./tsconfig.esm.json" + } + ] + }, + "default": { + "name": "default", + "description": "Synthesize project files" + }, + "eslint": { + "name": "eslint", + "description": "Runs eslint against the codebase", + "steps": [ + { + "exec": "eslint $@ src test", + "receiveArgs": true + } + ] + }, + "install": { + "name": "install", + "description": "Install project dependencies and update lockfile (non-frozen)", + "steps": [ + { + "exec": "pnpm i --no-frozen-lockfile" + } + ] + }, + "install:ci": { + "name": "install:ci", + "description": "Install project dependencies using frozen lockfile", + "steps": [ + { + "exec": "pnpm i --frozen-lockfile" + } + ] + }, + "package": { + "name": "package", + "description": "Creates the distribution package", + "steps": [ + { + "exec": "build-utils pack-v2" + } + ] + }, + "post-compile": { + "name": "post-compile", + "description": "Runs after successful compilation" + }, + "pre-compile": { + "name": "pre-compile", + "description": "Prepare the project for compilation", + "steps": [ + { + "spawn": "eslint" + } + ] + }, + "test": { + "name": "test", + "description": "Run tests", + "steps": [ + { + "exec": "vitest run --reporter verbose", + "receiveArgs": true + } + ] + }, + "test:watch": { + "name": "test:watch", + "description": "Run tests in watch mode", + "steps": [ + { + "exec": "vitest --reporter verbose" + } + ] + }, + "watch": { + "name": "watch", + "description": "Watch & compile in the background", + "steps": [ + { + "exec": "tsc --build -w" + } + ] + } + }, + "env": { + "PATH": "$(pnpm -c exec \"node --print process.env.PATH\")" + }, + "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." +} diff --git a/packages/cloudfront/LICENSE b/packages/cloudfront/LICENSE new file mode 100644 index 00000000..dc1bf0b6 --- /dev/null +++ b/packages/cloudfront/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2026 Victor Korzunin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/cloudfront/README.md b/packages/cloudfront/README.md new file mode 100644 index 00000000..af18ccc9 --- /dev/null +++ b/packages/cloudfront/README.md @@ -0,0 +1,90 @@ +# @effect-aws/cloudfront + +[![npm version](https://img.shields.io/npm/v/%40effect-aws%2Fcloudfront?color=brightgreen&label=npm%20package)](https://www.npmjs.com/package/@effect-aws/cloudfront) +[![npm downloads](https://img.shields.io/npm/dm/%40effect-aws%2Fcloudfront)](https://www.npmjs.com/package/@effect-aws/cloudfront) + +This package provides Effect modules for AWS CloudFront utilities. + +## Installation + +```bash +npm install --save @effect-aws/cloudfront +``` + +## Usage + +### Signed URL + +```ts +import { CloudFrontSigner } from "@effect-aws/cloudfront" +import { Effect } from "effect" + +const program = CloudFrontSigner.getSignedUrl({ + url: "https://d111111abcdef8.cloudfront.net/private-content/private.jpeg", + dateLessThan: new Date(Date.now() + 60 * 60 * 1000) +}) + +const result = program.pipe( + Effect.provide( + CloudFrontSigner.layer({ + keyPairId: "K2JCJMDEHXQW5F", + privateKey: "CONTENTS-OF-PRIVATE-KEY" + }) + ), + Effect.runPromise +) +``` + +### Signed Cookies + +```ts +import { CloudFrontSigner } from "@effect-aws/cloudfront" +import { Effect } from "effect" + +const program = CloudFrontSigner.getSignedCookies({ + url: "https://d111111abcdef8.cloudfront.net/private-content/*", + dateLessThan: new Date(Date.now() + 60 * 60 * 1000) +}) + +const result = program.pipe( + Effect.provide( + CloudFrontSigner.layer({ + keyPairId: "K2JCJMDEHXQW5F", + privateKey: "CONTENTS-OF-PRIVATE-KEY" + }) + ), + Effect.runPromise +) +``` + +### Custom Policy + +```ts +import { CloudFrontSigner } from "@effect-aws/cloudfront" +import { Effect } from "effect" + +const policy = JSON.stringify({ + Statement: [ + { + Resource: "https://d111111abcdef8.cloudfront.net/private-content/*", + Condition: { + DateLessThan: { + "AWS:EpochTime": Math.floor(Date.now() / 1000) + 3600 + } + } + } + ] +}) + +const program = CloudFrontSigner.getSignedUrl({ policy }) + +const result = program.pipe( + Effect.provide( + CloudFrontSigner.layer({ + keyPairId: "K2JCJMDEHXQW5F", + privateKey: "CONTENTS-OF-PRIVATE-KEY" + }) + ), + Effect.runPromise +) +``` diff --git a/packages/cloudfront/docgen.json b/packages/cloudfront/docgen.json new file mode 100644 index 00000000..cc12dbc6 --- /dev/null +++ b/packages/cloudfront/docgen.json @@ -0,0 +1,8 @@ +{ + "$schema": "../../node_modules/@effect/docgen/schema.json", + "exclude": [ + "src/internal/**/*.ts", + "src/Errors.ts" + ], + "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." +} diff --git a/packages/cloudfront/package.json b/packages/cloudfront/package.json new file mode 100644 index 00000000..b2562133 --- /dev/null +++ b/packages/cloudfront/package.json @@ -0,0 +1,51 @@ +{ + "name": "@effect-aws/cloudfront", + "description": "Effectful AWS CloudFront modules", + "repository": { + "type": "git", + "url": "github:floydspace/effect-aws", + "directory": "packages/cloudfront" + }, + "scripts": { + "build": "npx projen build", + "compile": "npx projen compile", + "default": "npx projen default", + "eslint": "npx projen eslint", + "package": "npx projen package", + "post-compile": "npx projen post-compile", + "pre-compile": "npx projen pre-compile", + "test": "npx projen test", + "test:watch": "npx projen test:watch", + "watch": "npx projen watch", + "docgen": "docgen" + }, + "author": { + "name": "Victor Korzunin", + "email": "ifloydrose@gmail.com", + "organization": false + }, + "devDependencies": { + "@types/node": "ts5.4", + "effect": "^3.16.4", + "typescript": "^5.4.2" + }, + "peerDependencies": { + "effect": ">=3.15.5 <4.0.0" + }, + "dependencies": { + "@aws-sdk/cloudfront-signer": "^3" + }, + "main": "build/cjs/index.js", + "license": "MIT", + "homepage": "https://floydspace.github.io/effect-aws/docs/cloudfront", + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "version": "0.0.0", + "types": "build/dts/index.d.ts", + "type": "module", + "module": "build/esm/index.js", + "sideEffects": [], + "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." +} diff --git a/packages/cloudfront/src/CloudFrontSigner.ts b/packages/cloudfront/src/CloudFrontSigner.ts new file mode 100644 index 00000000..608334af --- /dev/null +++ b/packages/cloudfront/src/CloudFrontSigner.ts @@ -0,0 +1,115 @@ +/** + * @since 0.1.0 + */ +import type { + CloudfrontSignedCookiesOutput, + CloudfrontSignerCredentials, + CloudfrontSignInput, + CloudfrontSignInputWithParameters, + CloudfrontSignInputWithPolicy, +} from "@aws-sdk/cloudfront-signer"; +import { getSignedCookies as awsGetSignedCookies, getSignedUrl as awsGetSignedUrl } from "@aws-sdk/cloudfront-signer"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +/** + * @since 0.1.0 + * @category models + */ +export type CloudFrontSignerCredentials = CloudfrontSignerCredentials; + +/** + * @since 0.1.0 + * @category models + */ +export type GetSignedUrlInput = + | Omit + | Omit; + +/** + * @since 0.1.0 + * @category models + */ +export type GetSignedCookiesInput = + | Omit + | Omit; + +interface CloudFrontSigner$ { + readonly _: unique symbol; + + /** + * Creates a signed URL string using a canned or custom policy. + * + * When using a custom policy, `url` may be omitted and the policy resource + * will be used as the signed URL. + * + * Errors thrown by the underlying AWS signer are surfaced as defects. + * @returns the input URL with signature attached as query parameters. + */ + getSignedUrl(input: GetSignedUrlInput): Effect.Effect; + + /** + * Creates signed cookies using a canned or custom policy. + * + * Errors thrown by the underlying AWS signer are surfaced as defects. + * @returns an object with keys/values that can be added to cookies. + */ + getSignedCookies( + input: GetSignedCookiesInput, + ): Effect.Effect; +} + +const makeCloudFrontSigner = ( + credentials: CloudFrontSignerCredentials, +): CloudFrontSigner$ => { + const withCredentials = ( + input: GetSignedUrlInput | GetSignedCookiesInput, + ): CloudfrontSignInput => ({ ...credentials, ...input }); + + return { + getSignedUrl: (input) => Effect.sync(() => awsGetSignedUrl(withCredentials(input))), + getSignedCookies: (input) => Effect.sync(() => awsGetSignedCookies(withCredentials(input))), + } as CloudFrontSigner$; +}; + +/** + * @since 0.1.0 + * @category models + */ +export class CloudFrontSigner extends Effect.Tag( + "@effect-aws/cloudfront/CloudFrontSigner", +)() { + /** + * @since 0.1.0 + * + * @example + * import { Effect, Layer } from "effect"; + * import { CloudFrontSigner } from "@effect-aws/cloudfront"; + * + * const signedUrl = CloudFrontSigner.getSignedUrl({ + * url: "https://d111111abcdef8.cloudfront.net/private-content/private.jpeg", + * dateLessThan: "2024-01-01", + * }).pipe( + * Effect.provide(CloudFrontSigner.layer({ + * keyPairId: "K2JCJMDEHXQW5F", + * privateKey: "CONTENTS-OF-PRIVATE-KEY", + * })) + * ); + */ + static readonly layer = (config: CloudFrontSigner.Config) => Layer.succeed(this, makeCloudFrontSigner(config)); +} + +/** + * @since 0.1.0 + */ +export declare namespace CloudFrontSigner { + /** + * @since 0.1.0 + */ + export type Config = CloudFrontSignerCredentials; + + /** + * @since 0.1.0 + */ + export type Type = CloudFrontSigner$; +} diff --git a/packages/cloudfront/src/index.ts b/packages/cloudfront/src/index.ts new file mode 100644 index 00000000..dc8347da --- /dev/null +++ b/packages/cloudfront/src/index.ts @@ -0,0 +1,4 @@ +/** + * @since 0.1.0 + */ +export * from "./CloudFrontSigner.js"; diff --git a/packages/cloudfront/test/CloudFrontSigner.test.ts b/packages/cloudfront/test/CloudFrontSigner.test.ts new file mode 100644 index 00000000..93db8387 --- /dev/null +++ b/packages/cloudfront/test/CloudFrontSigner.test.ts @@ -0,0 +1,205 @@ +import { CloudFrontSigner } from "@effect-aws/cloudfront"; +import { Cause, Effect, Exit } from "effect"; +import { pipe } from "effect/Function"; +import { describe, expect, it } from "vitest"; + +const testPrivateKey = `-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDIkujotZTYElMo +Gd4lVpikdunqOFWBRBYDvkI3AHCmh6nTzZZY4OBInqpgWyVMyUKxp9TxGvENsDeR +5AWCmlKepRmoQkw+jE6jKVOjIrxBgBxO1yDlusbvktmOA72q6cGRGnnC6oIIk3u1 +UsBN7fy9Uj/AfbyEAMdylqIHKj2TQVQSSx1DPd2461g+beyyv5FRuJTH9i3gNqKg +4f6n4IFeVpmQnSvyuPApOT9GRFCDXrwhAWI0aDsR70EsvQ7rX5orY/VsP6WZnt3K ++5DRCFNiGL+JfAitoBgr4s2jaNImnbgz4dhGHoefz2WmnrVW5gtiist6r5u/eAlc +QxK8UTFNAgMBAAECggEAAI4LfLO35P5T+/a3J+iv6zb0mbQsysenie+2Ti5ybiHo +JRDBO2vpb7n+Par/roRmBCJmbLS6Evzrgm9PjP3Xs0TaQWsBiMpcxodgPSf+iyEL +TJqJJqmLUFfAwbhRh4E2PfkFRwLc3jo0nuexZkwwvuaIP535bgh0hMtVSNDKMwSV +F1dV3MDl5v0FuN8goVicRxL/VWCHYVwmnEiQbJO+0y5iO8wZB8K28MQ5/BHaf9AQ +JeSuBG6gDcxreH1b6843KD6X2FCuSHVUScw8L8xjBPiyVey3A+Lov+ci7RBNgsOl +8NeetxYb5lgtDcPIwI8/CounPsQ87LbzrV+B2C0JHwKBgQDyvm3EdcMwXyi93CO/ +yREuUuNN/7bkDcIRP/MGh7I8HBiWEchhtLZPZcOhDOzceB8GWBCbhJxzFNxUirT2 +s13oZvr0F0L6LUBo2qL7yi8833zh8Jl2SWuI76+fypPKgdJgI0AfXPvKHcvdxL2h +8kq2CdS/aU9axNR6jZpAA9G59wKBgQDThvFAY+puQJdhWMJWCj3QlxWnZpaclSEo +swIFVybIrj/4OeFRboU9GewDwPu3KakaPdEhvtg6C+/zHWaLNSGEf26rC6qiMJ98 +PkG6rjXVMUDr1B5iqcihSsNyeKHzW21LyzcssA5JFOYFotltRvTay2ARZ9DN90h+ +ZH9I7lL92wKBgQDW7Hhv2/yoVXjTMZiNdE87/rO3GdQq82uCrmZUv8y1oAK8zJ3o +tII+plGhxLnrMof1q/94Ut/anPu/9tfnHkTlQliryfTJYUuICx0HZqqQcSg3PDoE +G1mU8vNCvbBBOtSzvkJGAqQE/oDEqC9mtgokR2wQ1gMm0ON4ZuvOBhOpXQKBgEg2 +CAQIWA6dZFBqQaWnx2R/P3gvcHkx++jt+2GhV+8odVQjRkVLapbqU4UQi54CzeTU +d6GCikR+sMKZJdmeILwf2MLxr6XA3aFHi57UPDJ0pwobFuhtt71ATaq1fewbKRu3 +2zUZQTc3uDLyXjGNCKfsx/YJL+IwagIkAsP+AD0DAoGAK2dtCpMI3DbPHxdOLKcS +0guCZtNlxBnC8uVCPBh6f6dYF4leREMl34mTv0zvS3S6IvHLUUJVJPzpMz6yoof5 +a4rvL+vauvR8bKtsUMcoaTLXgqZeo22evsJLWFAwUJ4lK8i+3MXvTQUKTIlVjkMu +dltFF4PcpRyh00WqTLx6i9o= +-----END PRIVATE KEY-----`; + +describe("CloudFrontSigner", () => { + const layer = CloudFrontSigner.layer({ + keyPairId: "K2JCJMDEHXQW5F", + privateKey: testPrivateKey, + }); + + describe("getSignedUrl", () => { + it("should generate a signed URL with canned policy", async () => { + const result = await pipe( + CloudFrontSigner.getSignedUrl({ + url: "https://d111111abcdef8.cloudfront.net/private-content/private.jpeg", + dateLessThan: new Date(Date.now() + 60 * 60 * 1000), + }), + Effect.provide(layer), + Effect.runPromiseExit, + ); + + expect(result).satisfies(Exit.isSuccess); + if (Exit.isSuccess(result)) { + expect(result.value).toContain( + "https://d111111abcdef8.cloudfront.net/private-content/private.jpeg", + ); + expect(result.value).toContain("Expires="); + expect(result.value).toContain("Signature="); + expect(result.value).toContain("Key-Pair-Id=K2JCJMDEHXQW5F"); + } + }); + + it("should generate a signed URL with custom policy", async () => { + const url = "https://d111111abcdef8.cloudfront.net/private-content/private.jpeg"; + const policy = JSON.stringify({ + Statement: [ + { + Resource: url, + Condition: { + DateLessThan: { + "AWS:EpochTime": Math.floor(Date.now() / 1000) + 3600, + }, + }, + }, + ], + }); + + const result = await pipe( + CloudFrontSigner.getSignedUrl({ + url, + policy, + }), + Effect.provide(layer), + Effect.runPromiseExit, + ); + + expect(result).satisfies(Exit.isSuccess); + if (Exit.isSuccess(result)) { + expect(result.value).toContain(url); + expect(result.value).toContain("Policy="); + expect(result.value).toContain("Signature="); + expect(result.value).toContain("Key-Pair-Id=K2JCJMDEHXQW5F"); + } + }); + + it("should use the policy resource when url is omitted", async () => { + const url = "https://d111111abcdef8.cloudfront.net/private-content/*"; + const policy = JSON.stringify({ + Statement: [ + { + Resource: url, + Condition: { + DateLessThan: { + "AWS:EpochTime": Math.floor(Date.now() / 1000) + 3600, + }, + }, + }, + ], + }); + + const result = await pipe( + CloudFrontSigner.getSignedUrl({ + policy, + }), + Effect.provide(layer), + Effect.runPromiseExit, + ); + + expect(result).satisfies(Exit.isSuccess); + if (Exit.isSuccess(result)) { + expect(result.value).toContain(url); + expect(result.value).toContain("Policy="); + expect(result.value).toContain("Key-Pair-Id=K2JCJMDEHXQW5F"); + } + }); + + it("should fail when the private key is invalid", async () => { + const invalidLayer = CloudFrontSigner.layer({ + keyPairId: "K2JCJMDEHXQW5F", + privateKey: "not-a-private-key", + }); + + const result = await pipe( + CloudFrontSigner.getSignedUrl({ + url: "https://d111111abcdef8.cloudfront.net/private-content/private.jpeg", + dateLessThan: new Date(Date.now() + 60 * 60 * 1000), + }), + Effect.provide(invalidLayer), + Effect.runPromiseExit, + ); + + expect(result).satisfies(Exit.isFailure); + if (Exit.isFailure(result)) { + expect(Cause.squash(result.cause)).toBeInstanceOf(Error); + } + }); + }); + + describe("getSignedCookies", () => { + it("should generate signed cookies with canned policy", async () => { + const result = await pipe( + CloudFrontSigner.getSignedCookies({ + url: "https://d111111abcdef8.cloudfront.net/private-content/*", + dateLessThan: new Date(Date.now() + 60 * 60 * 1000), + }), + Effect.provide(layer), + Effect.runPromiseExit, + ); + + expect(result).satisfies(Exit.isSuccess); + if (Exit.isSuccess(result)) { + expect(result.value).toHaveProperty( + "CloudFront-Key-Pair-Id", + "K2JCJMDEHXQW5F", + ); + expect(result.value).toHaveProperty("CloudFront-Signature"); + expect(result.value).toHaveProperty("CloudFront-Expires"); + } + }); + + it("should generate signed cookies with custom policy", async () => { + const url = "https://d111111abcdef8.cloudfront.net/private-content/*"; + const policy = JSON.stringify({ + Statement: [ + { + Resource: url, + Condition: { + DateLessThan: { + "AWS:EpochTime": Math.floor(Date.now() / 1000) + 3600, + }, + }, + }, + ], + }); + + const result = await pipe( + CloudFrontSigner.getSignedCookies({ + policy, + }), + Effect.provide(layer), + Effect.runPromiseExit, + ); + + expect(result).satisfies(Exit.isSuccess); + if (Exit.isSuccess(result)) { + expect(result.value).toHaveProperty( + "CloudFront-Key-Pair-Id", + "K2JCJMDEHXQW5F", + ); + expect(result.value).toHaveProperty("CloudFront-Signature"); + expect(result.value).toHaveProperty("CloudFront-Policy"); + } + }); + }); +}); diff --git a/packages/cloudfront/tsconfig.cjs.json b/packages/cloudfront/tsconfig.cjs.json new file mode 100644 index 00000000..5d9330be --- /dev/null +++ b/packages/cloudfront/tsconfig.cjs.json @@ -0,0 +1,10 @@ +// ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". +{ + "extends": "./tsconfig.src.json", + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/cjs.tsbuildinfo", + "outDir": "build/cjs", + "moduleResolution": "node", + "module": "CommonJS" + } +} diff --git a/packages/cloudfront/tsconfig.dev.json b/packages/cloudfront/tsconfig.dev.json new file mode 100644 index 00000000..f97384ef --- /dev/null +++ b/packages/cloudfront/tsconfig.dev.json @@ -0,0 +1,20 @@ +// ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/test.tsbuildinfo", + "noEmit": true, + "rootDir": "test", + "types": [ + "../../vitest.d.ts" + ] + }, + "include": [ + "test" + ], + "references": [ + { + "path": "tsconfig.src.json" + } + ] +} diff --git a/packages/cloudfront/tsconfig.esm.json b/packages/cloudfront/tsconfig.esm.json new file mode 100644 index 00000000..0725cdeb --- /dev/null +++ b/packages/cloudfront/tsconfig.esm.json @@ -0,0 +1,10 @@ +// ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". +{ + "extends": "./tsconfig.src.json", + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/esm.tsbuildinfo", + "outDir": "build/esm", + "declarationDir": "build/dts", + "stripInternal": true + } +} diff --git a/packages/cloudfront/tsconfig.json b/packages/cloudfront/tsconfig.json new file mode 100644 index 00000000..c1c93439 --- /dev/null +++ b/packages/cloudfront/tsconfig.json @@ -0,0 +1,13 @@ +// ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". +{ + "extends": "../../tsconfig.base.json", + "include": [], + "references": [ + { + "path": "tsconfig.src.json" + }, + { + "path": "tsconfig.dev.json" + } + ] +} diff --git a/packages/cloudfront/tsconfig.src.json b/packages/cloudfront/tsconfig.src.json new file mode 100644 index 00000000..dd8b4304 --- /dev/null +++ b/packages/cloudfront/tsconfig.src.json @@ -0,0 +1,12 @@ +// ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/src.tsbuildinfo", + "outDir": "build/src", + "rootDir": "src" + }, + "include": [ + "src" + ] +} diff --git a/packages/cloudfront/vitest.config.ts b/packages/cloudfront/vitest.config.ts new file mode 100644 index 00000000..2cf045fa --- /dev/null +++ b/packages/cloudfront/vitest.config.ts @@ -0,0 +1,6 @@ +import { mergeConfig, type UserConfigExport } from "vitest/config"; +import configShared from "../../vitest.shared.js"; + +const config: UserConfigExport = {}; + +export default mergeConfig(configShared, config); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8838e18f..39abf52d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1309,6 +1309,23 @@ importers: version: 5.4.5 publishDirectory: dist + packages/cloudfront: + dependencies: + '@aws-sdk/cloudfront-signer': + specifier: ^3 + version: 3.1036.0 + devDependencies: + '@types/node': + specifier: ts5.4 + version: 25.6.0 + effect: + specifier: ^3.16.4 + version: 3.16.4 + typescript: + specifier: ^5.4.2 + version: 5.4.5 + publishDirectory: dist + packages/commons: dependencies: '@smithy/protocol-http': @@ -1928,6 +1945,10 @@ packages: resolution: {integrity: sha512-4Ak1LqNLskcc/aMthXM6exlmajsfMiz5qotGTVFYGL/i/abRHSStGyC4GqILCD3eXiuhTXSWm6gkFVoTwJfz+g==} engines: {node: '>=20.0.0'} + '@aws-sdk/cloudfront-signer@3.1036.0': + resolution: {integrity: sha512-AXEl7lGvlbWbN0Xi0dd0XVfzVnA39dUyMILPcTk6Q5DkwmPgp6M/K6A6Ejbjajhoe8C1AUF4O1+hDc7DPxnBxg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.974.5': resolution: {integrity: sha512-lMPlYlYfQdNZhlkJgnkmESwrY+hNh3PljmZ+37oAqLNdJ6rnILAwFSyc6B3bJeDOtMORNnMQIej0aTRuOlDyhQ==} engines: {node: '>=20.0.0'} @@ -8800,6 +8821,12 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/cloudfront-signer@3.1036.0': + dependencies: + '@smithy/core': 3.23.17 + '@smithy/url-parser': 4.2.14 + tslib: 2.8.1 + '@aws-sdk/core@3.974.5': dependencies: '@aws-sdk/types': 3.973.8 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 01d06a45..4a80e9af 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -60,6 +60,7 @@ packages: - packages/client-timestream-influxdb - packages/client-timestream-query - packages/client-timestream-write + - packages/cloudfront - packages/commons - packages/dsql - packages/dynamodb diff --git a/tsconfig.base.json b/tsconfig.base.json index cd2b56eb..c28d61ec 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -677,6 +677,15 @@ ], "@effect-aws/dsql/test/*": [ "./packages/dsql/test/*.js" + ], + "@effect-aws/cloudfront": [ + "./packages/cloudfront/src/index.js" + ], + "@effect-aws/cloudfront/*": [ + "./packages/cloudfront/src/*.js" + ], + "@effect-aws/cloudfront/test/*": [ + "./packages/cloudfront/test/*.js" ] } } diff --git a/tsconfig.build.json b/tsconfig.build.json index 136dd510..55ba4340 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -212,6 +212,9 @@ }, { "path": "packages/dsql/tsconfig.esm.json" + }, + { + "path": "packages/cloudfront/tsconfig.esm.json" } ] } diff --git a/tsconfig.json b/tsconfig.json index ba01e398..3104e5ab 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -217,6 +217,9 @@ }, { "path": "packages/dsql" + }, + { + "path": "packages/cloudfront" } ] } diff --git a/vitest.shared.ts b/vitest.shared.ts index 507478e3..f88023ab 100644 --- a/vitest.shared.ts +++ b/vitest.shared.ts @@ -88,6 +88,7 @@ const config: UserConfig = { ...alias("s3"), ...alias("http-handler"), ...alias("dsql"), + ...alias("cloudfront"), } } }