diff --git a/.changeset/brand-email-templates.md b/.changeset/brand-email-templates.md new file mode 100644 index 0000000..6f5627d --- /dev/null +++ b/.changeset/brand-email-templates.md @@ -0,0 +1,5 @@ +--- +"@nxsflow/amplify-overtone": patch +--- + +Align email templates with Overtone brand guidelines and fix plain text content parity diff --git a/.changeset/fix-custom-output-key.md b/.changeset/fix-custom-output-key.md new file mode 100644 index 0000000..8b639a7 --- /dev/null +++ b/.changeset/fix-custom-output-key.md @@ -0,0 +1,5 @@ +--- +"@nxsflow/amplify-overtone": patch +--- + +Fix custom output key to match AWS::Amplify::Custom schema (must be customOutputs, not customEmailOutputs) diff --git a/.changeset/fix-custom-outputs-structure.md b/.changeset/fix-custom-outputs-structure.md new file mode 100644 index 0000000..f006c38 --- /dev/null +++ b/.changeset/fix-custom-outputs-structure.md @@ -0,0 +1,5 @@ +--- +"@nxsflow/amplify-overtone": patch +--- + +Fix amplify_outputs.json structure for custom email outputs diff --git a/.changeset/fix-identity-drift.md b/.changeset/fix-identity-drift.md new file mode 100644 index 0000000..9bf3e8e --- /dev/null +++ b/.changeset/fix-identity-drift.md @@ -0,0 +1,5 @@ +--- +"@nxsflow/amplify-overtone": patch +--- + +Handle SES identity drift by re-creating identities on every deploy if they were deleted externally diff --git a/.changeset/fix-npm-dist-alpha.md b/.changeset/fix-npm-dist-alpha.md new file mode 100644 index 0000000..00f3a53 --- /dev/null +++ b/.changeset/fix-npm-dist-alpha.md @@ -0,0 +1,5 @@ +--- +"@nxsflow/amplify-overtone": patch +--- + +Fix npm alpha dist-tag not advancing on pre-release publishes diff --git a/.changeset/idempotent-email-identity.md b/.changeset/idempotent-email-identity.md new file mode 100644 index 0000000..9bafb69 --- /dev/null +++ b/.changeset/idempotent-email-identity.md @@ -0,0 +1,5 @@ +--- +"@nxsflow/amplify-overtone": minor +--- + +Make email address identity creation idempotent to prevent deployment failures when SES identities already exist diff --git a/.changeset/lambda-backed-custom-resource.md b/.changeset/lambda-backed-custom-resource.md new file mode 100644 index 0000000..80d29a0 --- /dev/null +++ b/.changeset/lambda-backed-custom-resource.md @@ -0,0 +1,5 @@ +--- +"@nxsflow/amplify-overtone": minor +--- + +Replace AwsCustomResource with Lambda-backed CustomResource for idempotent email identity and add unit tests for the identity handler diff --git a/.claude/settings.json b/.claude/settings.json index 22df689..33e8325 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,7 +1,9 @@ { "permissions": { "allow": [ + "Bash(biome check:*)", "Bash(cat:*)", + "Bash(curl -s http://localhost:3000)", "Bash(find:*)", "Bash(gh api:*)", "Bash(gh pr checks:*)", @@ -10,6 +12,7 @@ "Bash(gh repo:*)", "Bash(gh run view:*)", "Bash(gh search:*)", + "Bash(ls .changeset/*.md)", "Bash(npm view:*)", "Bash(npx biome:*)", "Bash(pnpm add:*)", @@ -24,9 +27,11 @@ "Bash(pnpm format)", "Bash(pnpm install:*)", "Bash(pnpm lint)", + "Bash(pnpm lint:*)", "Bash(pnpm list:*)", "Bash(pnpm overtone:build)", "Bash(pnpm overtone:test)", + "Bash(pnpm overtone:test:*)", "Bash(pnpm overtone:typecheck)", "Bash(pnpm test)", "Bash(pnpm test-infra:bootstrap)", @@ -35,10 +40,12 @@ "Bash(pnpm test-infra:destroy)", "Bash(pnpm test-infra:typecheck)", "Bash(pnpm typecheck:*)", + "Bash(pnpm --filter website build)", "Fetch", "mcp__aws-knowledge-mcp-server__*", "mcp__awslabs_aws-documentation-mcp-server__*", "mcp__playwright__*", + "Read(//Users/ckoch/.claude/**)", "Skill", "WebFetch", "WebSearch" diff --git a/.claude/skills/release-management/SKILL.md b/.claude/skills/release-management/SKILL.md deleted file mode 100644 index 2f5b018..0000000 --- a/.claude/skills/release-management/SKILL.md +++ /dev/null @@ -1,266 +0,0 @@ ---- -name: release-management -description: CI/CD pipeline and publishing workflow for @nxsflow/amplify-overtone and @nxsflow/amplify-overtone-client — GitHub Actions workflows, tiered quality gates (alpha/beta/stable), npm dist-tags, manual fallback, and rollback. Use when publishing to npm, setting up CI/CD, or managing releases. ---- - -# Release Management: Amplify Overtone Monorepo - -CI/CD pipeline and publishing workflow. How code gets from main to npm. - -## Published Packages - -This monorepo publishes two packages independently: - -| Package | Path | npm | -| ---------------------------------- | ----------------------------------- | ------------------ | -| `@nxsflow/amplify-overtone` | `packages/amplify-overtone/` | Backend construct | -| `@nxsflow/amplify-overtone-client` | `packages/amplify-overtone-client/` | Server-side client | - -Changesets handles multi-package versioning automatically. Each package has its own `CHANGELOG.md` and version in its `package.json`. A single changeset can bump one or both packages. - -## Branch Strategy - -| Branch | Purpose | Dist-tag | Trigger | -| --------- | ------------------ | -------- | --------------------------- | -| `main` | Stable releases | `latest` | Merge "Version Packages" PR | -| `alpha/*` | Alpha pre-releases | `alpha` | Push to branch | -| `beta/*` | Beta pre-releases | `beta` | Push to branch | - -Pre-release mode (`changeset pre enter`) is only used on feature branches, never on main. - ---- - -## Tiered Quality Gates - -| Channel | Gates | Rationale | -| -------- | ------------------------------------------------- | ----------------------------------- | -| `alpha` | `pnpm build` + `pnpm typecheck` | Fast iteration, types must be sound | -| `beta` | + `pnpm test` + `pnpm lint` | Feature-complete, full validation | -| `stable` | + manual approval (GitHub environment protection) | Rock-solid, human sign-off | - ---- - -## GitHub Actions Workflows - -### `ci.yml` — PR validation - -Runs on all pull requests to main. All four gates must pass. - -```yaml -name: CI - -on: - pull_request: - branches: [main] - -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - -jobs: - ci: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm build - - run: pnpm typecheck - - run: pnpm test - - run: pnpm lint -``` - -### `publish.yml` — stable + pre-release publishing - -Single workflow for all publishing. Uses npm Trusted Publishing (OIDC) — no npm token required. - -- **main**: full validation + changesets/action (creates Version PR or publishes with `latest` tag) -- **beta/\*\***: full validation + `changeset publish` (pre-release with `beta` tag) -- **alpha/\*\***: build + typecheck only + `changeset publish` (pre-release with `alpha` tag) - -Stable releases require manual approval via the `production` GitHub environment. - -```yaml -name: Publish - -on: - push: - branches: - - main - - "alpha/**" - - "beta/**" - -concurrency: - group: publish-${{ github.ref }} - cancel-in-progress: false - -jobs: - publish: - runs-on: ubuntu-latest - environment: ${{ github.ref == 'refs/heads/main' && 'production' || '' }} - permissions: - contents: write - pull-requests: write - id-token: write # Required for npm Trusted Publishing (OIDC) - steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: pnpm - registry-url: https://registry.npmjs.org - - run: pnpm install --frozen-lockfile - - run: pnpm build - - run: pnpm typecheck - - name: Run tests - if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/beta/') - run: pnpm test - - name: Run lint - if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/beta/') - run: pnpm lint - - name: Create Release PR or Publish (stable) - if: github.ref == 'refs/heads/main' - uses: changesets/action@v1 - with: - publish: pnpm changeset publish - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Publish pre-release - if: github.ref != 'refs/heads/main' - run: pnpm changeset publish -``` - ---- - -## npm Setup - -### Trusted Publishing (OIDC) - -Publishing uses npm Trusted Publishing via GitHub Actions OIDC — no npm token required. Each package must be configured on npmjs.com: - -1. Go to npmjs.com → package settings → "Trusted Publisher" -2. Select "GitHub Actions" -3. Set: Organization = `nxsflow`, Repository = `amplify-overtone`, Workflow = `publish.yml` -4. Leave Environment empty (the workflow handles environment selection internally) - -### Repository Secrets - -| Secret | Purpose | Where to get it | -| -------------- | --------------------- | ---------------------------------------- | -| `GITHUB_TOKEN` | Create PRs, push tags | Provided automatically by GitHub Actions | - -**Do NOT use `NPM_TOKEN`.** Publishing uses npm Trusted Publishing (OIDC) — GitHub Actions authenticates directly with npm via short-lived tokens. Long-lived npm tokens are a security risk and should not be stored as secrets. - -### GitHub Environment - -Create a `production` environment in GitHub repo settings: - -1. Settings → Environments → New environment → "production" -2. Add required reviewers (at least one) -3. The `release.yml` workflow references this environment for manual approval - -### First Publish - -Scoped packages (`@nxsflow/*`) default to private on npm. Each package's first publish must use: - -```bash -pnpm --filter @nxsflow/amplify-overtone publish --access public -pnpm --filter @nxsflow/amplify-overtone-client publish --access public -``` - -After the first publish, subsequent publishes via `pnpm changeset publish` inherit the access level. Changesets will publish both packages in one command when both have pending version bumps. - ---- - -## What Gets Published - -Each package publishes only its `dist/` directory (controlled by `"files": ["dist"]` in each `package.json`): - -``` -packages/amplify-overtone/dist/ packages/amplify-overtone-client/dist/ -├── index.js ← ESM entry ├── index.js ← ESM entry -├── index.cjs ← CJS entry ├── index.cjs ← CJS entry -├── index.d.ts ← TypeScript ├── index.d.ts ← TypeScript -└── *.js.map ← source maps └── *.js.map ← source maps -``` - -Consumers import as: - -```typescript -// Backend (amplify/backend.ts) -import { n } from "@nxsflow/amplify-overtone"; - -// Server-side client (app code) -import { generateClient } from "@nxsflow/amplify-overtone-client"; -``` - ---- - -## Manual Fallback - -For publishing without CI (e.g., CI is broken, urgent hotfix): - -### Stable Release - -```bash -# 1. Ensure clean main branch -git checkout main && git pull - -# 2. Build and verify (all gates) -pnpm build && pnpm typecheck && pnpm test && pnpm lint - -# 3. Version bump -pnpm changeset version -git add package.json CHANGELOG.md .changeset -git commit -m "chore(release): bump version" - -# 4. Publish with latest tag -pnpm changeset publish -git push --follow-tags -``` - -### Pre-release - -```bash -# 1. Ensure correct branch -git checkout alpha/my-feature - -# 2. Build and verify (alpha: build + typecheck only) -pnpm build && pnpm typecheck - -# 3. Version bump -pnpm changeset version -git add package.json CHANGELOG.md .changeset -git commit -m "chore(release): bump alpha version" - -# 4. Publish with explicit dist-tag -pnpm changeset publish --tag alpha -git push --follow-tags -``` - -### Safety Checklist - -Before any manual publish: - -- [ ] Correct branch? (`main` for stable, `alpha/*` or `beta/*` for pre-release) -- [ ] All tier-appropriate gates pass? -- [ ] Correct dist-tag? (never publish pre-release without `--tag`) -- [ ] `npm view @nxsflow/amplify-overtone dist-tags` shows expected state after publish? -- [ ] `npm view @nxsflow/amplify-overtone-client dist-tags` shows expected state after publish? - ---- - -## Rollback - -| Scenario | Action | -| --------------- | ----------------------------------------------------------------------------- | -| Within 72 hours | `npm unpublish @nxsflow/amplify-overtone@X.Y.Z` (repeat for client if needed) | -| After 72 hours | `npm deprecate @nxsflow/amplify-overtone@X.Y.Z "use X.Y.Z+1"` + publish patch | -| Pre-release | Publish the next pre-release number (`-alpha.1` replaces `-alpha.0`) | - -**Note:** `npm unpublish` removes the version entirely. Use it only for broken releases. When rolling back, check if both packages need action or just one. diff --git a/.claude/skills/version-management/SKILL.md b/.claude/skills/version-management/SKILL.md deleted file mode 100644 index 0fc7de1..0000000 --- a/.claude/skills/version-management/SKILL.md +++ /dev/null @@ -1,212 +0,0 @@ ---- -name: version-management -description: Changesets-based version management for @nxsflow/amplify-overtone and @nxsflow/amplify-overtone-client — semver guide, stable workflow, alpha/beta/rc pre-release channels, npm dist-tags, changelog format, and peer dependency ranges. Use when bumping versions, creating changesets, entering pre-release mode, or deciding on bump type. ---- - -# Version Management: @nxsflow/amplify-overtone Monorepo - -How and when versions are bumped. Uses `@changesets/cli` for all version management. - -## Tooling - -- **Version manager**: `@changesets/cli` (semver, changelog generation, multi-package) -- **Package manager**: pnpm (workspace mode) -- **Repo structure**: pnpm monorepo with independently versioned packages -- **Config**: `.changeset/config.json` - -## Published Packages - -| Package | Path | Versioned independently | -| ---------------------------------- | ----------------------------------- | ----------------------- | -| `@nxsflow/amplify-overtone` | `packages/amplify-overtone/` | Yes | -| `@nxsflow/amplify-overtone-client` | `packages/amplify-overtone-client/` | Yes | - -Each package has its own `package.json` version and `CHANGELOG.md`. A single changeset can select one or both packages to bump. - ---- - -## Semantic Versioning Guide - -| Bump | When | Example | -| ------- | ---------------------------------------------------------------- | ----------------------------------- | -| `patch` | Bug fix, docs, internal refactor, new optional prop with default | Fix handler timeout config | -| `minor` | New optional `OvertoneProps` field, new export, new utility | Add `wireOvertoneToAuth()` | -| `major` | Rename prop, change `OvertoneResources` shape, remove export | Rename `config` to `overtoneConfig` | - -### Decision Guide - -Ask yourself: - -- **Can existing consumers upgrade without changing their code?** → patch or minor -- **Does it add new capabilities without breaking existing usage?** → minor -- **Will existing consumers need to change their code?** → major - ---- - -## Stable Workflow - -### 1. Create a changeset (on feature branch) - -```bash -pnpm changeset -``` - -Choose bump type (patch/minor/major) and describe the change. A `.changeset/.md` file is created. Commit it with the feature: - -```bash -git add .changeset/ -git commit -m "feat: add new feature - -" -``` - -### 2. Merge to main - -Open a PR and merge. CI handles the rest: - -- `changesets/action` detects pending changesets -- Creates a "Version Packages" PR that bumps `package.json` and updates `CHANGELOG.md` - -### 3. Merge the Version Packages PR - -This triggers `pnpm changeset publish` via CI, which: - -- Publishes to npm with the `latest` tag -- Creates a git tag (`v0.2.0`) - ---- - -## Pre-release Channels - -Three tiers with escalating quality gates: - -### Alpha — early iteration, breaking changes expected - -```bash -# Create a branch for the pre-release work -git checkout -b alpha/my-feature - -# Enter alpha pre-release mode -pnpm changeset pre enter alpha - -# Create changesets as normal -pnpm changeset - -# Bump to pre-release version (e.g., 0.2.0-alpha.0) -pnpm changeset version - -# Publish with the alpha dist-tag -pnpm changeset publish --tag alpha -``` - -Subsequent changes on this branch increment the pre-release number: `0.2.0-alpha.1`, `0.2.0-alpha.2`, etc. - -### Beta — feature-complete, needs validation - -```bash -git checkout -b beta/my-feature - -pnpm changeset pre enter beta -pnpm changeset -pnpm changeset version # 0.2.0-beta.0 -pnpm changeset publish --tag beta -``` - -### RC (optional) — release candidate, final validation - -```bash -pnpm changeset pre enter rc -pnpm changeset -pnpm changeset version # 0.2.0-rc.0 -pnpm changeset publish --tag beta # still uses beta tag, or create an rc tag -``` - -### Exiting Pre-release Mode - -When the pre-release is validated and ready for stable: - -```bash -# Exit pre-release mode -pnpm changeset pre exit - -# Version bumps to stable (0.2.0) -pnpm changeset version - -# Commit and merge to main -git add package.json CHANGELOG.md .changeset -git commit -m "chore(release): exit pre-release, bump to 0.2.0" -``` - -Merging to main triggers the stable release via CI. - -**Rule:** Pre-release mode (`changeset pre enter`) is only used on feature branches, never on main. - ---- - -## npm Dist-tags - -| Tag | Channel | Who installs it | -| -------- | ------------------ | -------------------------------------------- | -| `latest` | Stable releases | Default `pnpm add @nxsflow/amplify-overtone` | -| `alpha` | Alpha pre-releases | `pnpm add @nxsflow/amplify-overtone@alpha` | -| `beta` | Beta pre-releases | `pnpm add @nxsflow/amplify-overtone@beta` | - -Both `@nxsflow/amplify-overtone` and `@nxsflow/amplify-overtone-client` use the same dist-tag scheme. - -**Critical:** Always publish pre-releases with an explicit `--tag` flag. Default `npm publish` / `pnpm changeset publish` sets the `latest` tag, which pushes a pre-release to all users. - -### Verify Dist-tags - -```bash -npm view @nxsflow/amplify-overtone dist-tags -npm view @nxsflow/amplify-overtone-client dist-tags -``` - -Expected output after an alpha release: - -``` -{ latest: '0.1.0', alpha: '0.2.0-alpha.0' } -``` - ---- - -## Changelog Format - -Auto-generated by changesets. Each entry includes the changeset hash and description: - -```markdown -## 0.2.0 - -### Minor Changes - -- abc1234: Add wireOvertoneToAuth() utility for wiring overtone to Cognito - -### Patch Changes - -- def5678: Fix handler timeout configuration - -## 0.2.0-beta.0 - -### Minor Changes - -- abc1234: Add wireOvertoneToAuth() utility for wiring overtone to Cognito -``` - -Do not edit `CHANGELOG.md` manually. If a changeset description needs fixing, edit the `.changeset/*.md` file before running `pnpm changeset version`. - ---- - -## Peer Dependency Ranges - -| Package | Range | Rule | -| --------------------------- | --------- | ------------------------------------------------ | -| `aws-cdk-lib` | `^2.0.0` | Permissive — consumer controls their CDK version | -| `constructs` | `^10.0.0` | CDK base class | -| `@aws-amplify/plugin-types` | `^1.0.0` | ConstructFactory interface | - -**Rules:** - -- Never bundle peer deps — they're in tsup's `external` list -- Test against the minimum supported version, not latest -- Keep ranges permissive (`^2.0.0` not `^2.170.0`) — consumers have their own CDK version constraints -- Bumping a peer dep minimum range is a **major** version bump for this library diff --git a/.claude/skills/versioning-and-releases/SKILL.md b/.claude/skills/versioning-and-releases/SKILL.md new file mode 100644 index 0000000..f5b1278 --- /dev/null +++ b/.claude/skills/versioning-and-releases/SKILL.md @@ -0,0 +1,167 @@ +--- +name: versioning-and-releases +description: Use when bumping versions, creating changesets, entering/exiting pre-release mode, deciding bump type, publishing to npm, managing alpha/beta branches, promoting features to alpha/beta/stable, or when the user says something is "ready for alpha", "ready for beta", or "ready to release". +--- + +# Versioning and Releases + +Uses `@changesets/cli` for versioning. CI (`.github/workflows/publish.yml`) handles publishing automatically — never publish manually unless CI is broken. + +## Packages + +| Package | npm | Versioned independently | +| ---------------------------------- | ----------------------------------- | ----------------------- | +| `@nxsflow/amplify-overtone` | `packages/amplify-overtone/` | Yes | +| `@nxsflow/amplify-overtone-client` | `packages/amplify-overtone-client/` | Yes | + +## Semver Guide + +| Bump | When | Example | +| ------- | ---------------------------------------------------------------- | ---------------------------------- | +| `patch` | Bug fix, docs, internal refactor, new optional prop with default | Fix handler timeout | +| `minor` | New optional prop, new export, new utility | Add `wireOvertoneToAuth()` | +| `major` | Rename prop, change `OvertoneResources` shape, remove export | Rename `config` → `overtoneConfig` | + +Quick test: Can consumers upgrade without code changes? → patch/minor. New capabilities? → minor. Breaking? → major. Bumping a peer dep minimum range is always **major**. + +## Branch Strategy + +| Branch | Purpose | Dist-tag | CI gates | +| ------- | ------------------------------------------- | -------- | ------------------------------------------------- | +| `main` | Stable releases | `latest` | build + typecheck + test + lint + manual approval | +| `alpha` | Integration branch for in-progress features | `alpha` | build + typecheck | +| `beta` | Validated features ready for release | `beta` | build + typecheck + test + lint | + +**`alpha` and `beta` are single integration branches, not per-feature.** Feature branches (`feat/*`) merge into `alpha`. When all features in alpha are validated, alpha is promoted to `beta` as a cohort. Beta is promoted to main for stable release. + +``` +feat/foo ──┐ +feat/bar ──┤──► alpha ──► beta ──► main +feat/baz ──┘ +``` + +**PRs target `alpha` (or `beta` if skipping alpha), never `main` directly** for feature work. Promotion between integration branches is done by merging branches and switching pre-release mode. + +### Selective Release + +If some features aren't ready when you want to release: + +1. Revert unfinished features from `alpha` +2. Promote remaining `alpha` → `beta` → `main` (releases as e.g. `0.3.0`) +3. Re-apply unfinished features to `alpha` (starts next version, e.g. `0.4.0-alpha.0`) + +This preserves the invariant that `beta` is always a superset of `alpha` at the moment of promotion. + +## Workflows + +### Feature Development (on `feat/*`) + +```bash +# Create changeset with your feature +pnpm changeset +git add .changeset/ && git commit -m "feat: description" +# Open PR targeting `alpha` (or `beta` if skipping alpha) +``` + +No `pre.json`, no version bumps on feature branches — just code and changesets. + +### "Ready for Alpha" — Merging a Feature into Alpha + +When the user says a feature is "ready for alpha": + +1. **Merge the feature PR into the `alpha` branch** +2. **If `alpha` doesn't have `pre.json` yet** (first feature), enter alpha pre-release mode: + + ```bash + git checkout alpha + pnpm changeset pre enter alpha + ``` + +3. **Version and publish:** + + ```bash + pnpm changeset version # bumps to X.Y.Z-alpha.N + git add .changeset/ packages/ + git commit -m "chore(release): version X.Y.Z-alpha.N" + git push # CI publishes automatically + ``` + +Subsequent features merged into alpha: just run `changeset version`, commit, push. The alpha counter increments automatically. + +### "Ready for Beta" — Promoting Alpha to Beta + +When the user says features are "ready for beta": + +1. **Merge alpha into beta and switch pre-release mode:** + + ```bash + git checkout beta && git merge alpha + pnpm changeset pre exit + pnpm changeset pre enter beta + pnpm changeset version # bumps to X.Y.Z-beta.0 + git add .changeset/ packages/ + git commit -m "chore(release): version X.Y.Z-beta.0" + git push # CI publishes automatically + ``` + +### "Ready to Release" — Promoting Beta to Main + +When the user says it's "ready to release" or "ready for stable": + +1. **Exit pre-release mode on the beta branch:** + + ```bash + git checkout beta + pnpm changeset pre exit + pnpm changeset version # bumps to stable X.Y.Z + git add .changeset/ packages/ + git commit -m "chore(release): exit pre-release, bump to X.Y.Z" + git push + ``` + +2. **Create a PR from `beta` → `main`** — CI runs all four gates +3. **After merge**, `changesets/action` creates a "Version Packages" PR or publishes directly. Stable publish requires manual approval via the `production` GitHub environment. + +**Key:** `pre.json` must be removed on the `beta` branch _before_ creating the PR to main. The CI guard validates that `pre.json` does not exist on `main`. + +## Rules + +- **`pre.json` must exist** on `alpha` and `beta` branches — CI validates this +- **`pre.json` must NOT exist** on `main` — CI rejects it +- **Never pass `--tag`** to `changeset publish` when in pre mode — pre mode handles dist-tags +- **Versioning on pre-release branches is local** (`changeset version` before push); on main, `changesets/action` handles it via a "Version Packages" PR +- **Never edit `CHANGELOG.md` manually** — edit `.changeset/*.md` files before running `changeset version` + +## Verify Dist-tags + +```bash +npm view @nxsflow/amplify-overtone dist-tags +npm view @nxsflow/amplify-overtone-client dist-tags +``` + +## CI Pipeline + +See `.github/workflows/publish.yml` for implementation. Key behaviors: + +- **main push**: `changesets/action` creates a "Version Packages" PR (bumps versions, updates changelogs). Merging that PR publishes to npm with `latest` tag after manual approval (`production` environment). +- **alpha/beta push**: Runs `changeset publish` directly. Pre mode sets the dist-tag automatically. +- **PRs to main**: `.github/workflows/ci.yml` runs all four gates (build, typecheck, test, lint). +- **Publishing uses npm Trusted Publishing (OIDC)** — no npm token needed. + +## Rollback + +| Scenario | Action | +| --------------- | ----------------------------------------------------------------------------- | +| Within 72 hours | `npm unpublish @nxsflow/amplify-overtone@X.Y.Z` | +| After 72 hours | `npm deprecate @nxsflow/amplify-overtone@X.Y.Z "use X.Y.Z+1"` + publish patch | +| Pre-release | Publish the next pre-release number (`-alpha.1` replaces `-alpha.0`) | + +## Peer Dependency Ranges + +| Package | Range | Rule | +| --------------------------- | --------- | ------------------------------------------ | +| `aws-cdk-lib` | `^2.0.0` | Permissive — consumer controls CDK version | +| `constructs` | `^10.0.0` | CDK base class | +| `@aws-amplify/plugin-types` | `^1.0.0` | ConstructFactory interface | + +Never bundle peer deps. Keep ranges permissive. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2796773..32ff7ff 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,8 +4,8 @@ on: push: branches: - main - - "alpha/**" - - "beta/**" + - alpha + - beta concurrency: group: publish-${{ github.ref }} @@ -36,13 +36,28 @@ jobs: # Full validation for main and beta branches - name: Run tests - if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/beta/') + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta' run: pnpm test - name: Run lint - if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/beta/') + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta' run: pnpm lint + # Guard: pre.json must NOT exist on main, MUST exist on pre-release branches + - name: Validate pre-release mode + run: | + if [[ "${{ github.ref }}" == refs/heads/main ]]; then + if [ -f .changeset/pre.json ]; then + echo "::error::pre.json must not exist on main — exit pre-release mode before merging" + exit 1 + fi + else + if [ ! -f .changeset/pre.json ]; then + echo "::error::pre.json is missing — run 'changeset pre enter alpha|beta' before publishing" + exit 1 + fi + fi + # Stable releases: changesets/action creates Version PR or publishes - name: Create Release PR or Publish (stable) if: github.ref == 'refs/heads/main' @@ -53,7 +68,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_CONFIG_PROVENANCE: true - # Pre-releases: publish directly with changeset pre mode + # Pre-releases: changeset pre mode (pre.json) handles the dist-tag - name: Publish pre-release if: github.ref != 'refs/heads/main' run: pnpm changeset publish diff --git a/biome.json b/biome.json index c358eeb..5d057d9 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.9/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.10/schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "files": { "ignoreUnknown": true, diff --git a/package.json b/package.json index 1c6540f..48153b5 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ }, "packageManager": "pnpm@10.33.0", "devDependencies": { - "@biomejs/biome": "^2.4.9", + "@biomejs/biome": "2.4.10", "@changesets/cli": "^2.30.0", "esbuild": "^0.27.4", "typescript": "^5.8.3" diff --git a/packages/amplify-overtone/package.json b/packages/amplify-overtone/package.json index edcbaec..fd868cc 100644 --- a/packages/amplify-overtone/package.json +++ b/packages/amplify-overtone/package.json @@ -47,6 +47,7 @@ "@aws-sdk/client-sesv2": "^3.1019.0", "@types/node": "^22.0.0", "aws-cdk-lib": "^2.170.0", + "aws-sdk-client-mock": "^4.1.0", "constructs": "^10.4.2", "esbuild": "^0.27.0", "tsup": "^8.4.0", diff --git a/packages/amplify-overtone/src/email/construct.ts b/packages/amplify-overtone/src/email/construct.ts index cee1717..1948fa6 100644 --- a/packages/amplify-overtone/src/email/construct.ts +++ b/packages/amplify-overtone/src/email/construct.ts @@ -27,6 +27,7 @@ import { PhysicalResourceId, } from "aws-cdk-lib/custom-resources"; import { Construct } from "constructs"; +import { IdempotentEmailIdentity } from "./idempotent-email-identity.js"; import type { EmailProps, EmailResources, SenderWithEmail, SenderWithPrefix } from "./types.js"; // Find the package root by walking up from this file's directory until we find package.json. @@ -204,8 +205,8 @@ export class AmplifyEmail extends Construct { const senderEmails = Object.values(normalizedSenders).map((s) => s.email); for (const [key, sender] of Object.entries(normalizedSenders)) { - new EmailIdentity(this, `SenderIdentity-${key}`, { - identity: Identity.email(sender.email), + new IdempotentEmailIdentity(this, `SenderIdentity-${key}`, { + email: sender.email, }); } @@ -254,8 +255,8 @@ export class AmplifyEmail extends Construct { if (sandboxRecipients.length > 0) { for (const [i, email] of sandboxRecipients.entries()) { - new EmailIdentity(this, `SandboxRecipient${i}`, { - identity: Identity.email(email), + new IdempotentEmailIdentity(this, `SandboxRecipient${i}`, { + email, }); } diff --git a/packages/amplify-overtone/src/email/factory.ts b/packages/amplify-overtone/src/email/factory.ts index 2ede524..35fd184 100644 --- a/packages/amplify-overtone/src/email/factory.ts +++ b/packages/amplify-overtone/src/email/factory.ts @@ -36,7 +36,7 @@ export class EmailFactory implements ConstructFactory; +} + +function makePhysicalId(email: string, preExisted: boolean): string { + return `ses-identity:${email}:${preExisted ? "preexisted" : "created"}`; +} + +function parsePhysicalId(physicalId: string): { email: string; preExisted: boolean } { + const parts = physicalId.split(":"); + return { email: parts[1] ?? "", preExisted: parts[2] === "preexisted" }; +} + +export const handler = async (event: CfnEvent): Promise => { + const email = event.ResourceProperties.Email; + + switch (event.RequestType) { + case "Create": { + let preExisted = false; + try { + await ses.send(new CreateEmailIdentityCommand({ EmailIdentity: email })); + } catch (error) { + if (error instanceof AlreadyExistsException) { + preExisted = true; + } else { + throw error; + } + } + console.log(`Create: ${email}, preExisted=${preExisted}`); + return { PhysicalResourceId: makePhysicalId(email, preExisted) }; + } + + case "Update": { + const oldEmail = event.OldResourceProperties?.Email; + if (oldEmail !== email) { + // Email changed → new physical ID triggers replacement (Create new + Delete old) + let preExisted = false; + try { + await ses.send(new CreateEmailIdentityCommand({ EmailIdentity: email })); + } catch (error) { + if (error instanceof AlreadyExistsException) { + preExisted = true; + } else { + throw error; + } + } + console.log(`Update (replacement): ${email}, preExisted=${preExisted}`); + return { PhysicalResourceId: makePhysicalId(email, preExisted) }; + } + // Email unchanged → ensure identity still exists (handles drift) + try { + await ses.send(new CreateEmailIdentityCommand({ EmailIdentity: email })); + console.log(`Update (re-created after drift): ${email}`); + } catch (error) { + if (error instanceof AlreadyExistsException) { + console.log(`Update (no-op, identity exists): ${email}`); + } else { + throw error; + } + } + return { PhysicalResourceId: event.PhysicalResourceId! }; + } + + case "Delete": { + const { email: identityEmail, preExisted } = parsePhysicalId(event.PhysicalResourceId!); + if (preExisted) { + console.log(`Skipping delete: ${identityEmail} pre-existed before stack creation`); + return { PhysicalResourceId: event.PhysicalResourceId! }; + } + try { + await ses.send(new DeleteEmailIdentityCommand({ EmailIdentity: identityEmail })); + console.log(`Deleted SES identity: ${identityEmail}`); + } catch (error) { + if (error instanceof NotFoundException) { + console.log(`Identity ${identityEmail} already deleted`); + } else { + throw error; + } + } + return { PhysicalResourceId: event.PhysicalResourceId! }; + } + } +}; diff --git a/packages/amplify-overtone/src/email/idempotent-email-identity.ts b/packages/amplify-overtone/src/email/idempotent-email-identity.ts new file mode 100644 index 0000000..3803bc8 --- /dev/null +++ b/packages/amplify-overtone/src/email/idempotent-email-identity.ts @@ -0,0 +1,86 @@ +import { existsSync, readFileSync } from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { CustomResource, Stack } from "aws-cdk-lib"; +import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam"; +import { Runtime } from "aws-cdk-lib/aws-lambda"; +import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; +import { Provider } from "aws-cdk-lib/custom-resources"; +import { Construct } from "constructs"; + +function findPackageRoot(startDir: string): string { + let dir = startDir; + while (dir !== path.dirname(dir)) { + const pkgPath = path.join(dir, "package.json"); + if (existsSync(pkgPath)) { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + if (pkg.name === "@nxsflow/amplify-overtone") { + return dir; + } + } + dir = path.dirname(dir); + } + throw new Error("Could not find @nxsflow/amplify-overtone package root"); +} + +const PACKAGE_ROOT = findPackageRoot(path.dirname(fileURLToPath(import.meta.url))); +const HANDLER_DIR = path.join(PACKAGE_ROOT, "src", "email", "functions", "idempotent-identity"); + +/** + * Creates an SES email-address identity idempotently. + * + * Uses a Lambda-backed CustomResource that calls SESv2 CreateEmailIdentity + * and treats AlreadyExistsException as success, recording whether the + * identity pre-existed. On delete, the identity is removed only if this + * construct originally created it (tracked via the physical resource ID). + * + * Use this instead of the CDK EmailIdentity L2 construct when the + * identity may already exist in the account/region (e.g. sandbox + * recipients or sender addresses shared across stacks). + */ +export class IdempotentEmailIdentity extends Construct { + public readonly email: string; + + constructor(scope: Construct, id: string, props: { email: string }) { + super(scope, id); + + this.email = props.email; + + const region = Stack.of(this).region; + const account = Stack.of(this).account; + + const handler = new NodejsFunction(this, "Handler", { + entry: path.join(HANDLER_DIR, "handler.ts"), + handler: "handler", + runtime: Runtime.NODEJS_22_X, + bundling: { externalModules: ["@aws-sdk/*"] }, + }); + + handler.addToRolePolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["ses:CreateEmailIdentity", "ses:DeleteEmailIdentity"], + // Use a wildcard for identity resources: during CloudFormation + // replacements (email property change), the IAM policy is updated + // to the new email before the Delete handler runs for the old one. + // A scoped ARN causes AccessDenied on the old identity's deletion. + resources: [`arn:aws:ses:${region}:${account}:identity/*`], + }), + ); + + const provider = new Provider(this, "Provider", { + onEventHandler: handler, + }); + + new CustomResource(this, "Identity", { + serviceToken: provider.serviceToken, + resourceType: "Custom::SesEmailIdentity", + properties: { + Email: props.email, + // Force an Update call on every deploy so the handler can + // re-create the identity if it was deleted externally (drift). + DeployToken: new Date().toISOString(), + }, + }); + } +} diff --git a/packages/amplify-overtone/src/email/templates/defaults/confirmation-code.ts b/packages/amplify-overtone/src/email/templates/defaults/confirmation-code.ts index 896ea03..8ab5efe 100644 --- a/packages/amplify-overtone/src/email/templates/defaults/confirmation-code.ts +++ b/packages/amplify-overtone/src/email/templates/defaults/confirmation-code.ts @@ -9,12 +9,12 @@ export const confirmationCodeTemplate: TemplateDefinition = { } const expiryHtml = data.expiresInMinutes - ? `

This code expires in ${data.expiresInMinutes} minutes.

` + ? `

This code expires in ${data.expiresInMinutes} minutes.

` : ""; - return `

Use the confirmation code below to verify your identity.

+ return `

Use the confirmation code below to verify your identity.

- ${data.code} + ${data.code} ${expiryHtml}
`; }, @@ -24,7 +24,11 @@ export const confirmationCodeTemplate: TemplateDefinition = { throw new Error('Template "confirmation-code" requires data.code'); } - const parts = [`Your confirmation code is: ${data.code}`]; + const parts = [ + "Use the confirmation code below to verify your identity.", + "", + `Your confirmation code is: ${data.code}`, + ]; if (data.expiresInMinutes) { parts.push(`This code expires in ${data.expiresInMinutes} minutes.`); diff --git a/packages/amplify-overtone/src/email/templates/defaults/getting-started.ts b/packages/amplify-overtone/src/email/templates/defaults/getting-started.ts index a58ff5d..3472c47 100644 --- a/packages/amplify-overtone/src/email/templates/defaults/getting-started.ts +++ b/packages/amplify-overtone/src/email/templates/defaults/getting-started.ts @@ -5,20 +5,20 @@ export const gettingStartedTemplate: TemplateDefinition = { renderHtml(data: Record, brandName: string): string { const greeting = data.userName - ? `

Hi ${data.userName},

` + ? `

Hi ${data.userName},

` : ""; const brand = brandName || "the app"; const ctaHtml = data.dashboardLink ? `` : ""; return `${greeting} -

Welcome to ${brand}! We're excited to have you on board.

-

You're all set and ready to get started. Explore the features and let us know if you have any questions.

+

Welcome to ${brand}! We're excited to have you on board.

+

You're all set and ready to get started. Explore the features and let us know if you have any questions.

${ctaHtml}`; }, @@ -32,7 +32,9 @@ ${ctaHtml}`; } parts.push(`Welcome to ${brand}! We're excited to have you on board.`); - parts.push("You're all set and ready to get started."); + parts.push( + "You're all set and ready to get started. Explore the features and let us know if you have any questions.", + ); if (data.dashboardLink) { parts.push(""); diff --git a/packages/amplify-overtone/src/email/templates/defaults/invite.ts b/packages/amplify-overtone/src/email/templates/defaults/invite.ts index 93a06e9..65ae83c 100644 --- a/packages/amplify-overtone/src/email/templates/defaults/invite.ts +++ b/packages/amplify-overtone/src/email/templates/defaults/invite.ts @@ -13,10 +13,10 @@ export const inviteTemplate: TemplateDefinition = { const resourceText = data.resourceName ? ` on ${data.resourceName}` : ""; - return `

${data.inviterName} invited you to collaborate${resourceText}.

-

Click the button below to accept the invitation and get started.

+ return `

${data.inviterName} invited you to collaborate${resourceText}.

+

Click the button below to accept the invitation and get started.

`; }, @@ -32,8 +32,10 @@ export const inviteTemplate: TemplateDefinition = { const parts = [ `${data.inviterName} invited you to collaborate${resourceText}.`, - "Accept Invitation:", - data.inviteLink, + "", + "Click the link below to accept the invitation and get started.", + "", + `Accept Invitation: ${data.inviteLink}`, ]; return parts.join("\n"); diff --git a/packages/amplify-overtone/src/email/templates/defaults/password-reset.ts b/packages/amplify-overtone/src/email/templates/defaults/password-reset.ts index 5ac6e65..c641a86 100644 --- a/packages/amplify-overtone/src/email/templates/defaults/password-reset.ts +++ b/packages/amplify-overtone/src/email/templates/defaults/password-reset.ts @@ -9,14 +9,14 @@ export const passwordResetTemplate: TemplateDefinition = { } const expiryHtml = data.expiresInMinutes - ? `

This link expires in ${data.expiresInMinutes} minutes.

` + ? `

This link expires in ${data.expiresInMinutes} minutes.

` : ""; - return `

We received a request to reset your password. Click the button below to choose a new one.

+ return `

We received a request to reset your password. Click the button below to choose a new one.

-

If you did not request a password reset, you can ignore this email.

+

If you did not request a password reset, you can ignore this email.

${expiryHtml}`; }, @@ -25,8 +25,13 @@ ${expiryHtml}`; throw new Error('Template "password-reset" requires data.resetLink'); } - const parts = [`Reset your password: ${data.resetLink}`]; - parts.push("If you did not request a password reset, you can ignore this email."); + const parts = [ + "We received a request to reset your password.", + "", + `Reset Password: ${data.resetLink}`, + "", + "If you did not request a password reset, you can ignore this email.", + ]; if (data.expiresInMinutes) { parts.push(`This link expires in ${data.expiresInMinutes} minutes.`); diff --git a/packages/amplify-overtone/src/email/templates/renderer.ts b/packages/amplify-overtone/src/email/templates/renderer.ts index 6870223..5be057b 100644 --- a/packages/amplify-overtone/src/email/templates/renderer.ts +++ b/packages/amplify-overtone/src/email/templates/renderer.ts @@ -26,21 +26,21 @@ const templateRegistry: Record = { function buildBaseHtml(brandName: string, content: string): string { const brandHtml = brandName - ? `${escapeHtml(brandName)}` + ? `${escapeHtml(brandName)}` : ""; return ` - - + +
- +
${brandHtml} - +
${content}
You received this email because you have an account with ${escapeHtml(brandName || "us")}.
You received this email because you have an account with ${escapeHtml(brandName || "us")}.
diff --git a/packages/amplify-overtone/test/construct/email/ses.test.ts b/packages/amplify-overtone/test/construct/email/ses.test.ts index 3f11bca..ad89e16 100644 --- a/packages/amplify-overtone/test/construct/email/ses.test.ts +++ b/packages/amplify-overtone/test/construct/email/ses.test.ts @@ -1,3 +1,4 @@ +import { Match } from "aws-cdk-lib/assertions"; import { describe, it } from "vitest"; import { createEmailTemplate, createNoDomainTemplate } from "./helpers.js"; @@ -26,11 +27,27 @@ describe("SES — Mode 3 (domain + Route 53)", () => { describe("SES — Mode 1 (no domain)", () => { const template = createNoDomainTemplate(); - it("creates an SES EmailIdentity for the sender address", () => { - template.hasResourceProperties("AWS::SES::EmailIdentity", { - EmailIdentity: "noreply@example.com", + it("creates an idempotent identity for the sender address", () => { + template.hasResourceProperties("Custom::SesEmailIdentity", { + Email: "noreply@example.com", + }); + // No L2 EmailIdentity — only the domain modes use those + template.resourceCountIs("AWS::SES::EmailIdentity", 0); + }); + + it("grants identity handler a wildcard SES policy for safe replacements", () => { + template.hasResourceProperties("AWS::IAM::Policy", { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Action: ["ses:CreateEmailIdentity", "ses:DeleteEmailIdentity"], + Resource: { + "Fn::Join": ["", Match.arrayWith([":identity/*"])], + }, + }), + ]), + }, }); - template.resourceCountIs("AWS::SES::EmailIdentity", 1); }); it("still creates a ConfigurationSet", () => { diff --git a/packages/amplify-overtone/test/construct/email/warnings.test.ts b/packages/amplify-overtone/test/construct/email/warnings.test.ts index 68777ca..bb67ece 100644 --- a/packages/amplify-overtone/test/construct/email/warnings.test.ts +++ b/packages/amplify-overtone/test/construct/email/warnings.test.ts @@ -28,18 +28,19 @@ describe("Sandbox recipients", () => { sandboxRecipients: ["dev@example.com", "qa@example.com"], }); - it("creates EmailIdentity for each sandbox recipient", () => { - template.hasResourceProperties("AWS::SES::EmailIdentity", { - EmailIdentity: "dev@example.com", + it("creates idempotent identity for each sandbox recipient", () => { + template.hasResourceProperties("Custom::SesEmailIdentity", { + Email: "dev@example.com", }); - template.hasResourceProperties("AWS::SES::EmailIdentity", { - EmailIdentity: "qa@example.com", + template.hasResourceProperties("Custom::SesEmailIdentity", { + Email: "qa@example.com", }); }); it("creates sender identity + 2 sandbox recipient identities", () => { - // 1 sender (noreply@example.com) + 2 sandbox recipients = 3 - template.resourceCountIs("AWS::SES::EmailIdentity", 3); + // 1 sender + 2 sandbox recipients = 3 custom resources, 0 L2 identities + template.resourceCountIs("Custom::SesEmailIdentity", 3); + template.resourceCountIs("AWS::SES::EmailIdentity", 0); }); }); @@ -50,10 +51,10 @@ describe("No domain — Mode 1", () => { annotations.hasWarning("*", Match.stringLikeRegexp("No custom domain configured")); }); - it("creates EmailIdentity for each sender address", () => { + it("creates idempotent identity for each sender address", () => { const template = createNoDomainTemplate(); - template.hasResourceProperties("AWS::SES::EmailIdentity", { - EmailIdentity: "noreply@example.com", + template.hasResourceProperties("Custom::SesEmailIdentity", { + Email: "noreply@example.com", }); }); @@ -64,11 +65,11 @@ describe("No domain — Mode 1", () => { support: { senderEmail: "support@example.com", displayName: "Support" }, }, } as EmailProps); - template.hasResourceProperties("AWS::SES::EmailIdentity", { - EmailIdentity: "noreply@example.com", + template.hasResourceProperties("Custom::SesEmailIdentity", { + Email: "noreply@example.com", }); - template.hasResourceProperties("AWS::SES::EmailIdentity", { - EmailIdentity: "support@example.com", + template.hasResourceProperties("Custom::SesEmailIdentity", { + Email: "support@example.com", }); }); }); @@ -78,8 +79,10 @@ describe("Sandbox recipients — with domain", () => { sandboxRecipients: ["dev@example.com"], }); - it("creates domain identity + sandbox recipient identity", () => { - // 1 domain + 1 sandbox recipient = 2 - template.resourceCountIs("AWS::SES::EmailIdentity", 2); + it("creates domain identity (L2) + sandbox recipient (idempotent)", () => { + // Domain identity uses L2 construct (DKIM tokens needed) + template.resourceCountIs("AWS::SES::EmailIdentity", 1); + // Sandbox recipient uses idempotent custom resource + template.resourceCountIs("Custom::SesEmailIdentity", 1); }); }); diff --git a/packages/amplify-overtone/test/unit/email/idempotent-identity-handler.test.ts b/packages/amplify-overtone/test/unit/email/idempotent-identity-handler.test.ts new file mode 100644 index 0000000..95f9389 --- /dev/null +++ b/packages/amplify-overtone/test/unit/email/idempotent-identity-handler.test.ts @@ -0,0 +1,202 @@ +import { + AlreadyExistsException, + CreateEmailIdentityCommand, + DeleteEmailIdentityCommand, + NotFoundException, + SESv2Client, +} from "@aws-sdk/client-sesv2"; +import { mockClient } from "aws-sdk-client-mock"; +import { afterEach, describe, expect, it } from "vitest"; +import { handler } from "../../../src/email/functions/idempotent-identity/handler.js"; + +const sesMock = mockClient(SESv2Client); + +afterEach(() => { + sesMock.reset(); +}); + +// --------------------------------------------------------------------------- +// Create +// --------------------------------------------------------------------------- + +describe("Create", () => { + it("creates a new identity and marks it as 'created'", async () => { + sesMock.on(CreateEmailIdentityCommand).resolves({}); + + const result = await handler({ + RequestType: "Create", + ResourceProperties: { Email: "new@example.com", ServiceToken: "" }, + }); + + expect(result.PhysicalResourceId).toBe("ses-identity:new@example.com:created"); + expect(sesMock.commandCalls(CreateEmailIdentityCommand)).toHaveLength(1); + expect(sesMock.commandCalls(CreateEmailIdentityCommand)[0]?.args[0].input).toEqual({ + EmailIdentity: "new@example.com", + }); + }); + + it("marks a pre-existing identity as 'preexisted'", async () => { + sesMock + .on(CreateEmailIdentityCommand) + .rejects(new AlreadyExistsException({ message: "Already exists", $metadata: {} })); + + const result = await handler({ + RequestType: "Create", + ResourceProperties: { Email: "existing@example.com", ServiceToken: "" }, + }); + + expect(result.PhysicalResourceId).toBe("ses-identity:existing@example.com:preexisted"); + }); + + it("propagates unexpected errors", async () => { + sesMock.on(CreateEmailIdentityCommand).rejects(new Error("Throttled")); + + await expect( + handler({ + RequestType: "Create", + ResourceProperties: { Email: "test@example.com", ServiceToken: "" }, + }), + ).rejects.toThrow("Throttled"); + }); +}); + +// --------------------------------------------------------------------------- +// Update +// --------------------------------------------------------------------------- + +describe("Update", () => { + it("ensures identity exists and preserves physical ID when email is unchanged", async () => { + sesMock + .on(CreateEmailIdentityCommand) + .rejects(new AlreadyExistsException({ message: "Already exists", $metadata: {} })); + + const result = await handler({ + RequestType: "Update", + ResourceProperties: { Email: "same@example.com", ServiceToken: "" }, + OldResourceProperties: { Email: "same@example.com", ServiceToken: "" }, + PhysicalResourceId: "ses-identity:same@example.com:created", + }); + + expect(result.PhysicalResourceId).toBe("ses-identity:same@example.com:created"); + expect(sesMock.commandCalls(CreateEmailIdentityCommand)).toHaveLength(1); + }); + + it("re-creates identity after drift and preserves physical ID", async () => { + sesMock.on(CreateEmailIdentityCommand).resolves({}); + + const result = await handler({ + RequestType: "Update", + ResourceProperties: { Email: "same@example.com", ServiceToken: "" }, + OldResourceProperties: { Email: "same@example.com", ServiceToken: "" }, + PhysicalResourceId: "ses-identity:same@example.com:created", + }); + + expect(result.PhysicalResourceId).toBe("ses-identity:same@example.com:created"); + expect(sesMock.commandCalls(CreateEmailIdentityCommand)).toHaveLength(1); + }); + + it("preserves 'preexisted' flag on update", async () => { + sesMock + .on(CreateEmailIdentityCommand) + .rejects(new AlreadyExistsException({ message: "Already exists", $metadata: {} })); + + const result = await handler({ + RequestType: "Update", + ResourceProperties: { Email: "same@example.com", ServiceToken: "" }, + OldResourceProperties: { Email: "same@example.com", ServiceToken: "" }, + PhysicalResourceId: "ses-identity:same@example.com:preexisted", + }); + + expect(result.PhysicalResourceId).toBe("ses-identity:same@example.com:preexisted"); + }); + + it("creates a new identity and returns a new physical ID when the email changes", async () => { + sesMock.on(CreateEmailIdentityCommand).resolves({}); + + const result = await handler({ + RequestType: "Update", + ResourceProperties: { Email: "new@example.com", ServiceToken: "" }, + OldResourceProperties: { Email: "old@example.com", ServiceToken: "" }, + PhysicalResourceId: "ses-identity:old@example.com:created", + }); + + expect(result.PhysicalResourceId).toBe("ses-identity:new@example.com:created"); + expect(sesMock.commandCalls(CreateEmailIdentityCommand)[0]?.args[0].input).toEqual({ + EmailIdentity: "new@example.com", + }); + }); + + it("detects pre-existing identity on email change", async () => { + sesMock + .on(CreateEmailIdentityCommand) + .rejects(new AlreadyExistsException({ message: "Already exists", $metadata: {} })); + + const result = await handler({ + RequestType: "Update", + ResourceProperties: { Email: "existing@example.com", ServiceToken: "" }, + OldResourceProperties: { Email: "old@example.com", ServiceToken: "" }, + PhysicalResourceId: "ses-identity:old@example.com:created", + }); + + expect(result.PhysicalResourceId).toBe("ses-identity:existing@example.com:preexisted"); + }); +}); + +// --------------------------------------------------------------------------- +// Delete +// --------------------------------------------------------------------------- + +describe("Delete", () => { + it("deletes an identity that was created by this stack", async () => { + sesMock.on(DeleteEmailIdentityCommand).resolves({}); + + const result = await handler({ + RequestType: "Delete", + ResourceProperties: { Email: "test@example.com", ServiceToken: "" }, + PhysicalResourceId: "ses-identity:test@example.com:created", + }); + + expect(result.PhysicalResourceId).toBe("ses-identity:test@example.com:created"); + expect(sesMock.commandCalls(DeleteEmailIdentityCommand)).toHaveLength(1); + expect(sesMock.commandCalls(DeleteEmailIdentityCommand)[0]?.args[0].input).toEqual({ + EmailIdentity: "test@example.com", + }); + }); + + it("skips deletion of a pre-existing identity", async () => { + const result = await handler({ + RequestType: "Delete", + ResourceProperties: { Email: "test@example.com", ServiceToken: "" }, + PhysicalResourceId: "ses-identity:test@example.com:preexisted", + }); + + expect(result.PhysicalResourceId).toBe("ses-identity:test@example.com:preexisted"); + expect(sesMock.commandCalls(DeleteEmailIdentityCommand)).toHaveLength(0); + }); + + it("ignores NotFoundException when identity was already deleted", async () => { + sesMock + .on(DeleteEmailIdentityCommand) + .rejects(new NotFoundException({ message: "Not found", $metadata: {} })); + + const result = await handler({ + RequestType: "Delete", + ResourceProperties: { Email: "test@example.com", ServiceToken: "" }, + PhysicalResourceId: "ses-identity:test@example.com:created", + }); + + expect(result.PhysicalResourceId).toBe("ses-identity:test@example.com:created"); + }); + + it("propagates unexpected errors on delete", async () => { + sesMock.on(DeleteEmailIdentityCommand).rejects(new Error("AccessDenied")); + + await expect( + handler({ + RequestType: "Delete", + ResourceProperties: { Email: "test@example.com", ServiceToken: "" }, + PhysicalResourceId: "ses-identity:test@example.com:created", + }), + ).rejects.toThrow("AccessDenied"); + }); +}); diff --git a/packages/integration-tests/src/utilities/amplify_outputs_validator.ts b/packages/integration-tests/src/utilities/amplify_outputs_validator.ts index 9e159ee..b051b1e 100644 --- a/packages/integration-tests/src/utilities/amplify_outputs_validator.ts +++ b/packages/integration-tests/src/utilities/amplify_outputs_validator.ts @@ -8,29 +8,10 @@ export interface EmailOutputs { } function getEmailOutputs(outputs: Record): EmailOutputs { - // Amplify serializes custom outputs as a JSON string under a numeric key. - // The structure is: { "0": "{\"custom\":{\"email\":{...}}}", ... } - // We need to find and parse that JSON string. let custom: Record | undefined; - // First, check if 'custom' exists at the top level (direct structure) if (outputs.custom && typeof outputs.custom === "object") { custom = outputs.custom as Record; - } else { - // Search numeric keys for a JSON string containing custom outputs - for (const value of Object.values(outputs)) { - if (typeof value === "string") { - try { - const parsed = JSON.parse(value) as Record; - if (parsed.custom && typeof parsed.custom === "object") { - custom = parsed.custom as Record; - break; - } - } catch { - // Not valid JSON, skip - } - } - } } assert.ok(custom, "amplify_outputs.json should contain custom email outputs"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2bd37ad..99f67c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: devDependencies: '@biomejs/biome': - specifier: ^2.4.9 - version: 2.4.9 + specifier: 2.4.10 + version: 2.4.10 '@changesets/cli': specifier: ^2.30.0 version: 2.30.0(@types/node@22.19.15) @@ -35,6 +35,9 @@ importers: aws-cdk-lib: specifier: ^2.170.0 version: 2.245.0(constructs@10.6.0) + aws-sdk-client-mock: + specifier: ^4.1.0 + version: 4.1.0 constructs: specifier: ^10.4.2 version: 10.6.0 @@ -1370,59 +1373,59 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.4.9': - resolution: {integrity: sha512-wvZW92FrwitTcacvCBT8xdAbfbxWfDLwjYMmU3djjqQTh7Ni4ZdiWIT/x5VcZ+RQuxiKzIOzi5D+dcyJDFZMsA==} + '@biomejs/biome@2.4.10': + resolution: {integrity: sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.4.9': - resolution: {integrity: sha512-d5G8Gf2RpH5pYwiHLPA+UpG3G9TLQu4WM+VK6sfL7K68AmhcEQ9r+nkj/DvR/GYhYox6twsHUtmWWWIKfcfQQA==} + '@biomejs/cli-darwin-arm64@2.4.10': + resolution: {integrity: sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.4.9': - resolution: {integrity: sha512-LNCLNgqDMG7BLdc3a8aY/dwKPK7+R8/JXJoXjCvZh2gx8KseqBdFDKbhrr7HCWF8SzNhbTaALhTBoh/I6rf9lA==} + '@biomejs/cli-darwin-x64@2.4.10': + resolution: {integrity: sha512-14fzASRo+BPotwp7nWULy2W5xeUyFnTaq1V13Etrrxkrih+ez/2QfgFm5Ehtf5vSjtgx/IJycMMpn5kPd5ZNaA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.4.9': - resolution: {integrity: sha512-8RCww5xnPn2wpK4L/QDGDOW0dq80uVWfppPxHIUg6mOs9B6gRmqPp32h1Ls3T8GnW8Wo5A8u7vpTwz4fExN+sw==} + '@biomejs/cli-linux-arm64-musl@2.4.10': + resolution: {integrity: sha512-WrJY6UuiSD/Dh+nwK2qOTu8kdMDlLV3dLMmychIghHPAysWFq1/DGC1pVZx8POE3ZkzKR3PUUnVrtZfMfaJjyQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [musl] - '@biomejs/cli-linux-arm64@2.4.9': - resolution: {integrity: sha512-4adnkAUi6K4C/emPRgYznMOcLlUqZdXWM6aIui4VP4LraE764g6Q4YguygnAUoxKjKIXIWPteKMgRbN0wsgwcg==} + '@biomejs/cli-linux-arm64@2.4.10': + resolution: {integrity: sha512-7MH1CMW5uuxQ/s7FLST63qF8B3Hgu2HRdZ7tA1X1+mk+St4JOuIrqdhIBnnyqeyWJNI+Bww7Es5QZ0wIc1Cmkw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.4.9': - resolution: {integrity: sha512-5TD+WS9v5vzXKzjetF0hgoaNFHMcpQeBUwKKVi3JbG1e9UCrFuUK3Gt185fyTzvRdwYkJJEMqglRPjmesmVv4A==} + '@biomejs/cli-linux-x64-musl@2.4.10': + resolution: {integrity: sha512-kDTi3pI6PBN6CiczsWYOyP2zk0IJI08EWEQyDMQWW221rPaaEz6FvjLhnU07KMzLv8q3qSuoB93ua6inSQ55Tw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@2.4.9': - resolution: {integrity: sha512-L10na7POF0Ks/cgLFNF1ZvIe+X4onLkTi5oP9hY+Rh60Q+7fWzKDDCeGyiHUFf1nGIa9dQOOUPGe2MyYg8nMSQ==} + '@biomejs/cli-linux-x64@2.4.10': + resolution: {integrity: sha512-tZLvEEi2u9Xu1zAqRjTcpIDGVtldigVvzug2fTuPG0ME/g8/mXpRPcNgLB22bGn6FvLJpHHnqLnwliOu8xjYrg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [glibc] - '@biomejs/cli-win32-arm64@2.4.9': - resolution: {integrity: sha512-aDZr0RBC3sMGJOU10BvG7eZIlWLK/i51HRIfScE2lVhfts2dQTreowLiJJd+UYg/tHKxS470IbzpuKmd0MiD6g==} + '@biomejs/cli-win32-arm64@2.4.10': + resolution: {integrity: sha512-umwQU6qPzH+ISTf/eHyJ/QoQnJs3V9Vpjz2OjZXe9MVBZ7prgGafMy7yYeRGnlmDAn87AKTF3Q6weLoMGpeqdQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.4.9': - resolution: {integrity: sha512-NS4g/2G9SoQ4ktKtz31pvyc/rmgzlcIDCGU/zWbmHJAqx6gcRj2gj5Q/guXhoWTzCUaQZDIqiCQXHS7BcGYc0w==} + '@biomejs/cli-win32-x64@2.4.10': + resolution: {integrity: sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -3278,6 +3281,18 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@11.2.2': + resolution: {integrity: sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==} + + '@sinonjs/fake-timers@15.3.0': + resolution: {integrity: sha512-m2xozxSfCIxjDdvbhIWazlP2i2aha/iUmbl94alpsIbd3iLTfeXgfBVbwyWogB6l++istyGZqamgA/EcqYf+Bg==} + + '@sinonjs/samsam@8.0.3': + resolution: {integrity: sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==} + '@smithy/abort-controller@4.2.12': resolution: {integrity: sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==} engines: {node: '>=18.0.0'} @@ -3680,6 +3695,12 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/sinon@17.0.4': + resolution: {integrity: sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew==} + + '@types/sinonjs__fake-timers@15.0.1': + resolution: {integrity: sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -3879,6 +3900,9 @@ packages: engines: {node: '>= 18.0.0'} hasBin: true + aws-sdk-client-mock@4.1.0: + resolution: {integrity: sha512-h/tOYTkXEsAcV3//6C1/7U4ifSpKyJvb6auveAepqqNJl6TdZaPFEtKjBQNf8UxQdDP850knB2i/whq4zlsxJw==} + b4a@1.8.0: resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==} peerDependencies: @@ -4360,6 +4384,10 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff@5.2.2: + resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} + engines: {node: '>=0.3.1'} + diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} @@ -5300,6 +5328,9 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + just-extend@6.2.0: + resolution: {integrity: sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==} + kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -5808,6 +5839,9 @@ packages: sass: optional: true + nise@6.1.4: + resolution: {integrity: sha512-vSA4IpRHRWZkmotu61SvF45Jirq4CTLT3KKOWJPsPMtxtOBOlxcAlXfv/OrWxkzAJiCBrvdfWvGQjHT7r7+Qqg==} + no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} @@ -6482,6 +6516,9 @@ packages: signedsource@1.0.0: resolution: {integrity: sha512-6+eerH9fEnNmi/hyM1DXcRK3pWdoMQtlkQ+ns0ntzunjKqp5i3sKCc80ym8Fib3iaYhdJUOPdhlJWj1tvge2Ww==} + sinon@18.0.1: + resolution: {integrity: sha512-a2N2TDY1uGviajJ6r4D1CyRAkzE9NNVlYOV1wX5xQDuAk0ONgzgRl0EjCQuRCPxOwp13ghsMwt9Gdldujs39qw==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -6811,6 +6848,14 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -10102,39 +10147,39 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@biomejs/biome@2.4.9': + '@biomejs/biome@2.4.10': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.4.9 - '@biomejs/cli-darwin-x64': 2.4.9 - '@biomejs/cli-linux-arm64': 2.4.9 - '@biomejs/cli-linux-arm64-musl': 2.4.9 - '@biomejs/cli-linux-x64': 2.4.9 - '@biomejs/cli-linux-x64-musl': 2.4.9 - '@biomejs/cli-win32-arm64': 2.4.9 - '@biomejs/cli-win32-x64': 2.4.9 - - '@biomejs/cli-darwin-arm64@2.4.9': + '@biomejs/cli-darwin-arm64': 2.4.10 + '@biomejs/cli-darwin-x64': 2.4.10 + '@biomejs/cli-linux-arm64': 2.4.10 + '@biomejs/cli-linux-arm64-musl': 2.4.10 + '@biomejs/cli-linux-x64': 2.4.10 + '@biomejs/cli-linux-x64-musl': 2.4.10 + '@biomejs/cli-win32-arm64': 2.4.10 + '@biomejs/cli-win32-x64': 2.4.10 + + '@biomejs/cli-darwin-arm64@2.4.10': optional: true - '@biomejs/cli-darwin-x64@2.4.9': + '@biomejs/cli-darwin-x64@2.4.10': optional: true - '@biomejs/cli-linux-arm64-musl@2.4.9': + '@biomejs/cli-linux-arm64-musl@2.4.10': optional: true - '@biomejs/cli-linux-arm64@2.4.9': + '@biomejs/cli-linux-arm64@2.4.10': optional: true - '@biomejs/cli-linux-x64-musl@2.4.9': + '@biomejs/cli-linux-x64-musl@2.4.10': optional: true - '@biomejs/cli-linux-x64@2.4.9': + '@biomejs/cli-linux-x64@2.4.10': optional: true - '@biomejs/cli-win32-arm64@2.4.9': + '@biomejs/cli-win32-arm64@2.4.10': optional: true - '@biomejs/cli-win32-x64@2.4.9': + '@biomejs/cli-win32-x64@2.4.10': optional: true '@cdklabs/tskb@0.0.4': {} @@ -11713,6 +11758,23 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@11.2.2': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@sinonjs/fake-timers@15.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@sinonjs/samsam@8.0.3': + dependencies: + '@sinonjs/commons': 3.0.1 + type-detect: 4.1.0 + '@smithy/abort-controller@4.2.12': dependencies: '@smithy/types': 4.13.1 @@ -12224,6 +12286,12 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/sinon@17.0.4': + dependencies: + '@types/sinonjs__fake-timers': 15.0.1 + + '@types/sinonjs__fake-timers@15.0.1': {} + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -12444,6 +12512,12 @@ snapshots: aws-cdk@2.1114.1: {} + aws-sdk-client-mock@4.1.0: + dependencies: + '@types/sinon': 17.0.4 + sinon: 18.0.1 + tslib: 2.8.1 + b4a@1.8.0: {} babel-generator@6.26.1: @@ -12944,6 +13018,8 @@ snapshots: dependencies: dequal: 2.0.3 + diff@5.2.2: {} + diff@8.0.4: {} dir-glob@3.0.1: @@ -14079,6 +14155,8 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + just-extend@6.2.0: {} + kleur@4.1.5: {} knex@2.4.2(mysql2@3.9.9)(pg@8.11.6): @@ -14793,6 +14871,13 @@ snapshots: - '@babel/core' - babel-plugin-macros + nise@6.1.4: + dependencies: + '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers': 15.3.0 + just-extend: 6.2.0 + path-to-regexp: 8.4.1 + no-case@3.0.4: dependencies: lower-case: 2.0.2 @@ -15594,6 +15679,15 @@ snapshots: signedsource@1.0.0: {} + sinon@18.0.1: + dependencies: + '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers': 11.2.2 + '@sinonjs/samsam': 8.0.3 + diff: 5.2.2 + nise: 6.1.4 + supports-color: 7.2.0 + slash@3.0.0: {} slice-ansi@4.0.0: @@ -15930,6 +16024,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + type-detect@4.0.8: {} + + type-detect@4.1.0: {} + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4