diff --git a/.github/agents/website-designer.agent.md b/.github/agents/website-designer.agent.md index c8e67b77..8d0e9c6b 100644 --- a/.github/agents/website-designer.agent.md +++ b/.github/agents/website-designer.agent.md @@ -277,6 +277,7 @@ For each breakpoint: |---------|-------------|-----| | After any component edit with user-visible text | `@i18n Reviewer` | Verify translations are complete and correct | | Website changes affect documented features or CLI usage | `@Document Maintainer` | Keep docs in sync | +| Adding or updating an SDK on the website | `sdk-release-checklist` skill | Ensures version badge, changelog, and i18n are all wired | | Website code needs quality review | `@Code Reviewer` | Multi-perspective read-only analysis | | Website JS/TS logic has a bug | `@Bug Hunter` | Reproduce and fix via TDD | | CSS or layout needs structural cleanup | `@Code Refactorer` | Safe incremental improvements | @@ -293,6 +294,7 @@ After all work complete: "Run `/smart-commit` to commit, then `/pr-sync` to open design system - DO NOT hardcode colors — always use CSS variables from `global.css` - DO NOT hardcode user-visible text — always use the i18n system +- DO NOT hardcode version numbers — use `__SDK_*_VERSION__` / `__APP_VERSION__` globals from `astro.config.mjs` - DO NOT use fixed pixel font sizes — use `clamp()` or relative units - DO NOT break existing responsive layouts when adding new sections - DO NOT add JavaScript frameworks (React, Vue, etc.) — use Astro components diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3d19d354..b908a8bd 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -285,3 +285,38 @@ AAA pattern with comment markers. - `make check-sdk-python` (black + isort + mypy strict) - `make format-sdk-python` (auto-format) - `make test-sdk-python` (all tests, requires Docker for acceptance) + +### Node.js SDK (`src/sdks/nodejs/`) + +**Architecture**: Layered (Domain → Application → Infrastructure), no DI framework. + +- **Domain** (`src/domain/`): `ISecretProvider` interface (async `getSecrets` batch method), `MapFileConfig`, `EnvilderOptions`, `ParsedMapFile` types, `SecretProviderType` enum +- **Application** (`src/application/`): + - `Envilder` — Async facade (`load`, `resolveFile`, `fromMapFile` + env-routing overloads) + - `EnvilderClient` — Core resolver (`resolveSecrets`, `injectIntoEnvironment` static method sets `process.env`) + - `MapFileParser` — Parses `$config` + variable mappings from JSON + - `validateSecrets` — Opt-in validation throws `SecretValidationError` for empty/missing values +- **Infrastructure** (`src/infrastructure/`): + - `createSecretProvider` (not re-exported from barrel) — Creates provider from `MapFileConfig` + optional `EnvilderOptions` overrides + - `AwsSsmSecretProvider` — `GetParametersCommand` (batch, up to 10 per request) with `WithDecryption: true`, missing parameters silently omitted via `InvalidParameters` + - `AzureKeyVaultSecretProvider` — `Promise.all` over `SecretClient.getSecret()` calls, catches 404 → omitted + +**Key patterns**: + +- Async-first — all provider and facade methods return `Promise` +- Interface-based ports — TypeScript `interface` for `ISecretProvider` +- `createSecretProvider` is not re-exported from the public barrel (`index.ts`) — consumers use the `Envilder` facade +- `Envilder` facade is the primary public API (fluent: `fromMapFile().withProvider().withVaultUrl().inject()`) +- `ISecretProvider.getSecrets(names[])` returns `Map` — missing secrets silently omitted +- `EnvilderClient.resolveSecrets()` delegates to `getSecrets()` in a single call +- `validateSecrets()` — opt-in post-resolution validation +- Cross-provider validation: profile + Azure → error, vaultUrl + AWS → error +- `Map` used for mappings and resolved secrets + +**Tests** (`tests/sdks/nodejs/`): Vitest with `Should__When_` naming. +AAA pattern with comment markers. `vi.fn()` for mocks. + +**Build & test**: + +- `cd src/sdks/nodejs && pnpm build` (TypeScript compilation) +- `cd tests/sdks/nodejs && pnpm vitest run --reporter=verbose` (unit tests) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e6c9e755..7f660306 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -90,8 +90,8 @@ updates: - package-ecosystem: npm directories: - - "/src/sdks/typescript" - - "/tests/sdks/typescript" + - "/src/sdks/nodejs" + - "/tests/sdks/nodejs" pull-request-branch-name: separator: "/" schedule: diff --git a/.github/skills/doc-sync/SKILL.md b/.github/skills/doc-sync/SKILL.md index b70d52fb..f0dcafa7 100644 --- a/.github/skills/doc-sync/SKILL.md +++ b/.github/skills/doc-sync/SKILL.md @@ -108,6 +108,8 @@ Use this matrix to ensure consistency when updating a feature: | CLI flag in `Cli.ts` | `docs/pull-command.md` or `docs/push-command.md`, root README, DocsContent, i18n | | GHA input in `action.yml` | `github-action/README.md`, `docs/github-action.md`, website DocsContent, i18n | | SDK public API | SDK README, examples README, website DocsContent + Sdks.astro, i18n | +| New SDK added | Run full `sdk-release-checklist` skill (version badge, changelog, i18n, docs) | +| SDK version bump | Bump canonical source file; changelog entry; website picks up version at build time | | New provider | All provider listings: root README, website Providers.astro, DocsContent, SDK READMEs | | Map-file format | Root README, all SDK READMEs, website DocsContent | | ROADMAP status | `ROADMAP.md`, website Roadmap.astro | diff --git a/.github/skills/sdk-acceptance-testing/SKILL.md b/.github/skills/sdk-acceptance-testing/SKILL.md new file mode 100644 index 00000000..d7269b31 --- /dev/null +++ b/.github/skills/sdk-acceptance-testing/SKILL.md @@ -0,0 +1,196 @@ +--- +name: sdk-acceptance-testing +description: >- + Acceptance testing patterns for Envilder SDKs using TestContainers + (LocalStack for AWS SSM, Lowkey Vault for Azure Key Vault). Use when + adding acceptance tests to any SDK, creating container wrappers, or + updating CI workflows for SDK test infrastructure. +--- + +# SDK Acceptance Testing + +Patterns for acceptance testing Envilder SDKs against real cloud provider +emulators. Applies to all SDKs (.NET, Python, TypeScript, Go, Java). + +See [ADR-0001](../../../docs/architecture/adr/0001-sdk-acceptance-test-infrastructure.md) +for the architectural decision behind these patterns. + +## When to Use + +- Adding acceptance tests to a new or existing SDK +- Creating container wrappers for LocalStack or Lowkey Vault +- Updating CI workflows to support SDK acceptance tests +- Adding a new SDK and need to replicate test infrastructure + +## Directory Structure + +Every SDK test directory follows this layout: + +```txt +tests/sdks/{lang}/ +├── containers/ ← Container wrapper modules +│ ├── localstack-container.* +│ └── lowkey-vault-container.* +├── acceptance/ ← Acceptance test files +│ ├── aws-ssm.acceptance.* +│ └── azure-key-vault.acceptance.* +└── {unit-tests}/ ← Language-specific unit test dirs +``` + +## secrets-map.json Pattern + +All SDKs reference the **root** `secrets-map.json` at the repository root +directly. Container wrappers navigate to it via a relative path — there are no +copies per SDK test directory. + +```json +{ + "$config": { + "provider": "aws", + "profile": "mac" + }, + "LOCALSTACK_AUTH_TOKEN": "/envilder/development/localstack/authToken" +} +``` + +**Resolution behavior:** + +| Environment | How it works | +|-------------|-------------| +| Local dev | `$config.profile` resolves AWS credentials from `~/.aws/credentials` | +| CI (GitHub Actions) | Profile is ignored; OIDC provides credentials via `aws-actions/configure-aws-credentials` | + +**Path resolution example (TypeScript):** + +```typescript +// From tests/sdks/nodejs/containers/localstack-container.ts +const SECRETS_MAP = path.resolve(__dirname, '../../../../secrets-map.json'); +``` + +**Fallback pattern:** If the configured provider cannot be created (e.g., Azure +credentials missing in a CI environment that only has AWS OIDC), fall back to +AWS provider to resolve the token. + +## LocalStack Container Wrapper + +### Requirements + +- Image: `localstack/localstack:stable` +- Resolve `LOCALSTACK_AUTH_TOKEN` from `secrets-map.json` before starting +- Throw if token is empty (fail fast) +- Expose: endpoint URL, SSM client, provider instance + +### Lifecycle + +```txt +1. Parse secrets-map.json with SDK's own MapFileParser +2. Resolve LOCALSTACK_AUTH_TOKEN using SDK's own EnvilderClient +3. Start container with token as environment variable +4. Expose connection URL for SSM client creation +``` + +## Lowkey Vault Container Wrapper + +### Requirements + +- Image: `nagyesta/lowkey-vault:7.1.61` (pinned) +- Ports: 8443 (HTTPS vault), 8080 (HTTP token endpoint) +- Args: `--server.port=8443 --LOWKEY_VAULT_RELAXED_PORTS=true` +- Set `IDENTITY_ENDPOINT` and `IDENTITY_HEADER` env vars for + `DefaultAzureCredential` +- Self-signed TLS: disable certificate verification in test clients +- Restore original env vars on teardown + +### Lifecycle + +```txt +1. Start container with Lowkey Vault args +2. Wait for HTTPS port to be ready (health check /ping) +3. Set IDENTITY_ENDPOINT = http://{host}:{http_port}/metadata/identity/oauth2/token +4. Set IDENTITY_HEADER = "dummy" +5. Create SecretClient with TLS verification disabled +6. On teardown: restore original IDENTITY_ENDPOINT/IDENTITY_HEADER +``` + +## Acceptance Test Patterns + +### Test Naming + +Same convention as unit tests: `Should_{Expected}_When_{Condition}` + +### Standard Tests per Provider + +Every SDK should have at minimum these acceptance tests: + +**AWS SSM:** + +1. `Should_ResolveSecretFromSsm_When_ParameterExistsInLocalStack` +2. `Should_ReturnEmptyForMissingSsmParameter_When_ParameterDoesNotExist` + +**Azure Key Vault:** + +1. `Should_ResolveSecretFromKeyVault_When_SecretExistsInLowkeyVault` +2. `Should_ReturnEmptyForMissingKeyVaultSecret_When_SecretDoesNotExist` + +### Test Structure (AAA) + +```typescript +it('Should_ResolveSecretFromSsm_When_ParameterExistsInLocalStack', async () => { + // Arrange + await ssmClient.send(new PutParameterCommand({ + Name: '/Test/MySecret', + Value: 'real-secret-from-localstack', + Type: 'SecureString', + Overwrite: true, + })); + const sut = new EnvilderClient(provider); + const mapFile: ParsedMapFile = { + config: {}, + mappings: new Map([['MY_SECRET', '/Test/MySecret']]), + }; + + // Act + const actual = await sut.resolveSecrets(mapFile); + + // Assert + expect(actual.get('MY_SECRET')).toBe('real-secret-from-localstack'); +}); +``` + +## CI Workflow Pattern + +### Required Steps + +```yaml +env: + LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }} + +steps: + - uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} + aws-region: ${{ secrets.AWS_REGION }} + + # ... build steps ... + + - name: Run tests + run: {test-command} + env: + TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE: /var/run/docker.sock + DOCKER_HOST: unix:///var/run/docker.sock +``` + +### Key Points + +- `LOCALSTACK_AUTH_TOKEN` at job level so container wrapper can read it +- AWS OIDC credentials for resolving the token from SSM +- Docker socket env vars for TestContainers compatibility on GitHub runners +- Acceptance tests run in the same job as unit tests (no separate workflow) + +## Existing Implementations + +| SDK | Container Wrappers | Acceptance Tests | +| ---------- | -------------------------------------- | ------------------------------------------------------------------------ | +| .NET | `tests/sdks/dotnet/Fixtures/` | `tests/sdks/dotnet/Infrastructure/`, `tests/sdks/dotnet/EndToEnd/` | +| Python | `tests/sdks/python/containers/` | `tests/sdks/python/infrastructure/`, `tests/sdks/python/end_to_end/` | +| Node.js | `tests/sdks/nodejs/containers/` | `tests/sdks/nodejs/acceptance/` | diff --git a/.github/skills/sdk-release-checklist/SKILL.md b/.github/skills/sdk-release-checklist/SKILL.md new file mode 100644 index 00000000..503e8b3e --- /dev/null +++ b/.github/skills/sdk-release-checklist/SKILL.md @@ -0,0 +1,136 @@ +--- +name: sdk-release-checklist +description: >- + Checklist for adding a new SDK or releasing a new SDK version. Covers code, + tests, website integration (docs page, version badge, changelog sidebar, + i18n keys), and CI wiring. Use when creating a new runtime SDK, bumping an + SDK version, or auditing that all integration points are connected. +--- + +# SDK Release Checklist + +Mandatory steps when adding a **new SDK** or **releasing a new version** of an +existing SDK. Prevents drift between code, website, changelogs, and CI. + +## When to Use + +- Creating a new runtime SDK (e.g., Go, Java, Ruby) +- Releasing a new version of any existing SDK (.NET, Python, TypeScript) +- Auditing that an SDK is fully wired into the website and build system +- After a version bump to verify all integration points are updated + +## New SDK — Full Checklist + +When adding a brand-new SDK to the project, complete **every** item: + +### 1. Code & Tests + +- [ ] SDK source under `src/sdks/{runtime}/` +- [ ] Unit tests under `tests/sdks/{runtime}/` +- [ ] Acceptance tests with LocalStack + Lowkey Vault containers + (see `sdk-acceptance-testing` skill) +- [ ] README.md in SDK root with install, quick start, and API reference + +### 2. Version Source File + +Every SDK must have a canonical version source read at build time: + +| Runtime | File | Field / Tag | +|------------|------------------------------------|------------------------| +| .NET | `src/sdks/dotnet/Envilder.csproj` | `X.Y.Z` | +| Python | `src/sdks/python/pyproject.toml` | `version = "X.Y.Z"` | +| Node.js | `src/sdks/nodejs/package.json` | `"version": "X.Y.Z"` | +| Go | Git tag / `go.mod` convention | `vX.Y.Z` tag | +| Java | `pom.xml` or `build.gradle` | `` / `version =` | + +### 3. Website — Version Badge Wiring + +The website reads SDK versions at **build time** via `astro.config.mjs`: + +- [ ] Add version extraction in `src/website/astro.config.mjs`: + - For `package.json`: read + `JSON.parse` (see Node.js SDK pattern) + - For `.csproj`: use `extractCsprojVersion()` helper (regex on ``) + - For `pyproject.toml`: use `extractPyprojectVersion()` helper (regex) +- [ ] Add Vite `define` global: `__SDK_{RUNTIME}_VERSION__` +- [ ] Declare in `src/website/src/env.d.ts`: `declare const __SDK_{RUNTIME}_VERSION__: string;` +- [ ] Use dynamic variable in `DocsContent.astro` badge (never hardcode versions) + +### 4. Website — Documentation Page Section + +- [ ] Add SDK section in `src/website/src/components/DocsContent.astro` + (install command, quick start code, badge, link to full docs) +- [ ] Add i18n keys to `src/website/src/i18n/types.ts` for all new strings +- [ ] Add translations to every locale file: `en.ts`, `ca.ts`, `es.ts` + +### 5. Website — Changelog Integration + +- [ ] Create `docs/changelogs/sdk-{runtime}.md` with initial release entry +- [ ] Read changelog in `astro.config.mjs` via `readChangelog()` helper +- [ ] Add Vite `define` global: `__CHANGELOG_SDK_{RUNTIME}__` +- [ ] Declare in `env.d.ts`: `declare const __CHANGELOG_SDK_{RUNTIME}__: string;` +- [ ] Add product entry to `products` array in **all 3** changelog pages: + - `src/website/src/pages/changelog.astro` + - `src/website/src/pages/ca/changelog.astro` + - `src/website/src/pages/es/changelog.astro` +- [ ] Add sidebar nav block (button + version list) in the SDKs group for + **all 3** changelog pages — maintain correct `parsed[N]` indices +- [ ] Add i18n key `categorySdk{Runtime}` to `types.ts` and all locale files +- [ ] Add to mobile product selector dropdown (automatic if in `products` array) + +### 6. Website — SDK Cards Component + +- [ ] Add card in `src/website/src/components/Sdks.astro` with install + command and package manager link + +### 7. Copilot Instructions & Build Config + +- [ ] Update `.github/copilot-instructions.md` with SDK architecture notes +- [ ] Add to `pnpm-workspace.yaml` if TypeScript/Node-based +- [ ] Add build/test commands to CI workflow + +### 8. Documentation Cross-References + +- [ ] Add SDK to root `README.md` SDK section +- [ ] Add SDK to `ROADMAP.md` if tracked there +- [ ] Add to `docs/changelogs/` index if one exists +- [ ] Run `doc-sync` skill to verify alignment + +## Existing SDK — Version Bump Checklist + +When releasing a new version of an already-wired SDK: + +- [ ] Bump version in the canonical source file (csproj / pyproject.toml / package.json) +- [ ] Add changelog entry to `docs/changelogs/sdk-{runtime}.md` +- [ ] Verify `pnpm build` in `src/website/` picks up the new version automatically +- [ ] No manual edits needed in `DocsContent.astro` (version is dynamic) + +## Validation + +After completing the checklist: + +```bash +# Build website and verify all versions render +cd src/website && pnpm build + +# Grep built HTML for version badges +node -e "const h=require('fs').readFileSync('dist/docs/index.html','utf-8'); \ + console.log(h.match(/NuGet v[\d.]+/)?.[0]); \ + console.log(h.match(/PyPI v[\d.]+/)?.[0]); \ + console.log(h.match(/npm v[\d.]+/)?.[0])" + +# Verify changelog page includes all SDKs +node -e "const h=require('fs').readFileSync('dist/changelog/index.html','utf-8'); \ + console.log('dotnet:', h.includes('sdk-dotnet')); \ + console.log('python:', h.includes('sdk-python')); \ + console.log('nodejs:', h.includes('sdk-nodejs'))" +``` + +## Common Pitfalls + +| Pitfall | Prevention | +|---------|------------| +| Hardcoded version in badge | Always use `__SDK_*_VERSION__` globals | +| Missing changelog sidebar entry | Check all 3 locale pages, not just `en` | +| Wrong `parsed[N]` index after adding SDK | Count products array entries carefully | +| Forgot i18n key in one locale | Add to `types.ts` first — TypeScript errors catch missing keys | +| Version not updating after bump | Restart dev server (Vite caches `define` values) | diff --git a/.github/skills/smart-commit/reference.md b/.github/skills/smart-commit/reference.md index b9abf27a..f4830e84 100644 --- a/.github/skills/smart-commit/reference.md +++ b/.github/skills/smart-commit/reference.md @@ -11,7 +11,7 @@ | `domain` | Domain entities, ports, errors | | `app` | Application layer handlers | | `infra` | Infrastructure adapters | -| `sdk-ts` | TypeScript SDK (`src/sdks/typescript/`) | +| `sdk-node` | Node.js SDK (`src/sdks/nodejs/`) | | `sdk-dotnet` | .NET SDK (`src/sdks/dotnet/`) | | `sdk-python` | Python SDK (`src/sdks/python/`) | | `sdk-go` | Go SDK (`src/sdks/go/`) | @@ -24,7 +24,7 @@ ## Scope Selection Heuristic -1. If all changes are in one SDK → use the matching `sdk-*` scope (e.g. `sdk-ts`, `sdk-dotnet`, `sdk-python`) +1. If all changes are in one SDK → use the matching `sdk-*` scope (e.g. `sdk-node`, `sdk-dotnet`, `sdk-python`) 2. If all changes are in one layer → use that layer scope (`domain`, `app`, `infra`) 3. If changes span CLI + core → use `cli` (user-facing entry point) 4. If changes span multiple SDKs → use `sdk` scope @@ -33,10 +33,10 @@ ## Examples ```bash -feat(sdk-ts): add fluent builder for secret resolution +feat(sdk-node): add fluent builder for secret resolution fix(ssm): handle throttling on GetParameter calls test(sdk-dotnet): add acceptance tests for Azure Key Vault -docs(website): update SDK landing page with TypeScript examples +docs(website): update SDK landing page with Node.js examples chore(dx): update biome to v2.0 refactor(app): extract shared validation into domain layer ``` diff --git a/.github/workflows/coverage-report.yml b/.github/workflows/coverage-report.yml index 4fc8609d..fc2012a1 100644 --- a/.github/workflows/coverage-report.yml +++ b/.github/workflows/coverage-report.yml @@ -227,6 +227,71 @@ jobs: path: report/ retention-days: 1 + # ──────────────────────────────────────────────────────────────────────────── + # � Node.js SDK + # ──────────────────────────────────────────────────────────────────────────── + test-nodejs-sdk: + if: ${{ !github.event.pull_request.draft }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + env: + LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }} + steps: + - name: 🧲 Checkout + uses: actions/checkout@v6 + + - name: 📦 Install pnpm + uses: pnpm/action-setup@v5 + with: + version: 10 + + - name: 🛠️ Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "20.x" + cache: "pnpm" + + - name: 📦 Install dependencies + run: pnpm install --frozen-lockfile + + - name: 🪙 Configure AWS credentials + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }} + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: 🏎️ Run tests with coverage + run: | + mkdir -p output + cd tests/sdks/nodejs && pnpm vitest run --reporter=verbose --coverage --coverage.reporter=lcov --coverage.reporter=text --coverage.reportsDirectory=${{ github.workspace }}/coverage-ts-sdk 2>&1 | tee ../../../output/test-output.txt + env: + TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE: /var/run/docker.sock + DOCKER_HOST: unix:///var/run/docker.sock + + - name: 📋 Generate coverage report + if: always() + uses: danielpalme/ReportGenerator-GitHub-Action@5.5.4 + with: + reports: coverage-ts-sdk/lcov.info + sourcedirs: src/sdks/nodejs/src + targetdir: report + reporttypes: Html;MarkdownSummaryGithub;Badges + filefilters: +*src/sdks/nodejs/src/*;-*node_modules/*;-*tests/*;-*index.ts + title: "Envilder Node.js SDK" + + - name: 📊 Extract test stats + if: always() + run: node .github/workflows/coverage-report/extract-test-stats.mjs output/test-output.txt report/test-stats.json vitest + + - name: 📤 Upload report + if: always() + uses: actions/upload-artifact@v7 + with: + name: nodejs-sdk + path: report/ + retention-days: 1 + # ──────────────────────────────────────────────────────────────────────────── # ☁️ IaC (CDK) # ──────────────────────────────────────────────────────────────────────────── @@ -284,7 +349,7 @@ jobs: # 📊 Publish aggregated dashboard # ──────────────────────────────────────────────────────────────────────────── publish-report: - needs: [test-core, test-dotnet, test-python, test-iac] + needs: [test-core, test-dotnet, test-python, test-nodejs-sdk, test-iac] if: ${{ always() }} runs-on: ubuntu-latest timeout-minutes: 5 @@ -298,6 +363,7 @@ jobs: [ "${{ needs.test-core.result }}" != "success" ] && failed="$failed core" [ "${{ needs.test-dotnet.result }}" != "success" ] && failed="$failed dotnet" [ "${{ needs.test-python.result }}" != "success" ] && failed="$failed python" + [ "${{ needs.test-nodejs-sdk.result }}" != "success" ] && failed="$failed nodejs-sdk" [ "${{ needs.test-iac.result }}" != "success" ] && failed="$failed iac" if [ -n "$failed" ]; then diff --git a/.github/workflows/coverage-report/coverage-config.json b/.github/workflows/coverage-report/coverage-config.json index 325a44cd..2575b2ad 100644 --- a/.github/workflows/coverage-report/coverage-config.json +++ b/.github/workflows/coverage-report/coverage-config.json @@ -12,6 +12,9 @@ "python": { "line": 80 }, + "nodejs-sdk": { + "line": 80 + }, "iac": { "line": 70 } diff --git a/.github/workflows/publish-npm-sdk.yml b/.github/workflows/publish-npm-sdk.yml new file mode 100644 index 00000000..ef78f8d8 --- /dev/null +++ b/.github/workflows/publish-npm-sdk.yml @@ -0,0 +1,123 @@ +name: 📦 Publish NPM SDK Package + +on: + workflow_dispatch: + + push: + branches: + - main + paths: + - "src/sdks/nodejs/**" + - "tests/sdks/nodejs/**" + - ".github/workflows/publish-npm-sdk.yml" + +permissions: + id-token: write + contents: write + +jobs: + publish-npm-sdk: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }} + + defaults: + run: + working-directory: src/sdks/nodejs + + steps: + - name: 🧱 Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: 📦 Install pnpm + uses: pnpm/action-setup@v5 + with: + version: 10 + + - name: 🛠️ Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "20.x" + registry-url: "https://registry.npmjs.org" + cache: "pnpm" + + - name: 👑 Detect version bump + id: version-check + run: | + CURRENT_VERSION=$(node -p "require('./package.json').version") + + HTTP_CODE=$(curl -s -o /tmp/npm-response.json -w '%{http_code}' \ + "https://registry.npmjs.org/@envilder/sdk/latest") + + if [ "$HTTP_CODE" = "200" ]; then + PUBLISHED_VERSION=$(jq -r '.version // "0.0.0"' /tmp/npm-response.json) + elif [ "$HTTP_CODE" = "404" ]; then + echo "Package not found on npm — first publish." + PUBLISHED_VERSION="0.0.0" + else + echo "::error::npm registry returned unexpected HTTP $HTTP_CODE" + exit 1 + fi + + echo "Current version: $CURRENT_VERSION, Published version: $PUBLISHED_VERSION" + + if [ "$CURRENT_VERSION" != "$PUBLISHED_VERSION" ]; then + echo "Version has been bumped from $PUBLISHED_VERSION to $CURRENT_VERSION" + echo "version_changed=true" >> $GITHUB_OUTPUT + echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + else + echo "Version not changed. Will not publish." + echo "version_changed=false" >> $GITHUB_OUTPUT + fi + + - name: 📦 Install dependencies + if: steps.version-check.outputs.version_changed == 'true' + run: pnpm install --frozen-lockfile + working-directory: . + + - name: 🪙 Collect Coins (Configure AWS credentials) + if: steps.version-check.outputs.version_changed == 'true' + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: 🌟 Format check + if: steps.version-check.outputs.version_changed == 'true' + run: npx biome check src/sdks/nodejs/src/ tests/sdks/nodejs/ + working-directory: . + + - name: 🏗️ Build + if: steps.version-check.outputs.version_changed == 'true' + run: pnpm build + + - name: 🍄 Run tests + if: steps.version-check.outputs.version_changed == 'true' + run: cd ../../../tests/sdks/nodejs && pnpm vitest run --reporter=verbose + env: + TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE: /var/run/docker.sock + DOCKER_HOST: unix:///var/run/docker.sock + + - name: 🚩 Publish to npm + if: steps.version-check.outputs.version_changed == 'true' + run: npm publish --provenance --access public + + - name: 🏁 Create version tag + if: steps.version-check.outputs.version_changed == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "sdk-nodejs/v${{ steps.version-check.outputs.current_version }}" -m "Release Envilder Node.js SDK v${{ steps.version-check.outputs.current_version }}" + git push origin "sdk-nodejs/v${{ steps.version-check.outputs.current_version }}" + + - name: 🏰 Create Release + if: steps.version-check.outputs.version_changed == 'true' + uses: ncipollo/release-action@v1 + with: + tag: "sdk-nodejs/v${{ steps.version-check.outputs.current_version }}" + name: "🟢 Envilder Node.js SDK v${{ steps.version-check.outputs.current_version }}" + generateReleaseNotes: true + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tests-nodejs-sdk.yml b/.github/workflows/tests-nodejs-sdk.yml new file mode 100644 index 00000000..a71a17d3 --- /dev/null +++ b/.github/workflows/tests-nodejs-sdk.yml @@ -0,0 +1,83 @@ +name: � Node.js SDK Tests + +permissions: + id-token: write + contents: read + checks: write + +on: + workflow_dispatch: + + pull_request: + branches: + - '*' + types: + - opened + - reopened + - synchronize + - ready_for_review + paths: + - '.github/workflows/tests-nodejs-sdk.yml' + - 'src/sdks/nodejs/**' + - 'tests/sdks/nodejs/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.sha }} + cancel-in-progress: true + +jobs: + test-nodejs-sdk: + runs-on: ubuntu-24.04 + if: ${{ !github.event.pull_request.draft }} + timeout-minutes: 10 + env: + LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }} + + steps: + - name: 🧱 Checkout repository + uses: actions/checkout@v6 + + - name: 📦 Install pnpm + uses: pnpm/action-setup@v5 + with: + version: 10 + + - name: 🛠️ Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "20.x" + cache: "pnpm" + + - name: 📦 Install dependencies + run: pnpm install --frozen-lockfile + + - name: 🪙 Collect Coins (Configure AWS credentials) + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: 🌟 Format check + run: npx biome check src/sdks/nodejs/src/ tests/sdks/nodejs/ + + - name: 🏗️ Build + run: cd src/sdks/nodejs && pnpm build + + - name: 🍄 Run tests + run: cd tests/sdks/nodejs && pnpm vitest run --reporter=verbose --reporter=junit --outputFile=${{ github.workspace }}/test-results.xml + env: + TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE: /var/run/docker.sock + DOCKER_HOST: unix:///var/run/docker.sock + + - name: 📊 Test Results Reporter + uses: dorny/test-reporter@v3 + if: always() + with: + name: Node.js SDK Test Results + path: test-results.xml + reporter: jest-junit + only-summary: "false" + list-suites: "all" + list-tests: "all" + max-annotations: "10" + fail-on-error: "true" diff --git a/ROADMAP.md b/ROADMAP.md index aff0acbd..5309c86f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,12 +33,12 @@ or directly inside application code at runtime. | **Onboarding documentation** | [Setup guide](./docs/requirements-installation.md) | | **.NET SDK** (`Envilder`) | First runtime SDK — load secrets into `IConfiguration` or `EnvilderClient`. AWS SSM + Azure Key Vault. [Documentation](./src/sdks/dotnet/README.md) | | **Python SDK** (`envilder`) | Runtime library for Python — Django, FastAPI, data pipelines. Sync API with `EnvilderClient`, `MapFileParser`, `SecretProviderFactory`. AWS SSM + Azure Key Vault. Published to PyPI. [Documentation](./src/sdks/python/README.md) | +| **Node.js SDK** (`@envilder/sdk`) | Runtime library for Node.js — load secrets directly into `process.env` from a map-file. AWS SSM + Azure Key Vault. Published to npm. [Documentation](./src/sdks/nodejs/README.md) | ### 🔥 Up Next | Feature | Priority | Notes | |---------|----------|-------| -| **TypeScript SDK** (`@envilder/sdk`) | 🔴 High | Native runtime library — load secrets directly into `process.env` from a map-file. No `.env` file needed. Published to npm | | **Go SDK** (`envilder`) | 🔴 High | Runtime library for Go — cloud-native apps, Kubernetes tooling. Published as Go module | | **Java SDK** (`envilder`) | 🔴 High | Runtime library for Java/Kotlin — Spring Boot, Android backends. Published to Maven Central | | **Map-file JSON Schema** | 🔴 High | Formal spec for the map-file format at `spec/` — serves as the contract between all SDKs and tools | @@ -82,7 +82,7 @@ All five SDKs are developed **in parallel** — same map-file contract, same con | SDK | Package | Registry | |-----|---------|----------| -| **TypeScript** | `@envilder/sdk` | npm | +| **Node.js** | `@envilder/sdk` | npm | | **Python** | `envilder` | PyPI | | **Go** | `envilder` | Go module | | **.NET** | `Envilder` | NuGet | @@ -92,7 +92,7 @@ All five SDKs are developed **in parallel** — same map-file contract, same con - **One map-file spec** — formal JSON Schema at `spec/` is the source of truth for all SDKs - **Conformance tests** — language-agnostic fixtures that every SDK must pass -- **Independent versioning** — each SDK has its own semver (`sdk-ts@1.2.0`, `sdk-py@0.3.0`) +- **Independent versioning** — each SDK has its own semver (`sdk-node@1.2.0`, `sdk-py@0.3.0`) - **Shared test infrastructure** — LocalStack (AWS) and Lowkey Vault (Azure) via Docker Compose serve all SDKs --- diff --git a/docs/architecture/adr/0001-sdk-acceptance-test-infrastructure.md b/docs/architecture/adr/0001-sdk-acceptance-test-infrastructure.md new file mode 100644 index 00000000..b6e086d9 --- /dev/null +++ b/docs/architecture/adr/0001-sdk-acceptance-test-infrastructure.md @@ -0,0 +1,127 @@ +# ADR-0001: SDK Acceptance Test Infrastructure + +## Status + +Accepted + +## Context + +Envilder is a multi-runtime secret management platform with SDKs for .NET, +Python, TypeScript (and planned Go, Java). Each SDK implements its own providers +for AWS SSM Parameter Store and Azure Key Vault. + +Acceptance tests must exercise real cloud provider interactions against +emulators: + +- **AWS SSM** via [LocalStack](https://localstack.cloud/) — requires + `LOCALSTACK_AUTH_TOKEN` (pro feature: SSM SecureString) +- **Azure Key Vault** via + [Lowkey Vault](https://github.com/nagyesta/lowkey-vault) — emulates Key Vault + with `DefaultAzureCredential` support + +Each SDK has its own test runner and language, but the infrastructure patterns +must be consistent to reduce cognitive overhead and ensure parity. + +## Decision + +### 1. Container Wrappers per SDK + +Each SDK implements its own container wrapper classes with explicit +`start()`/`stop()` lifecycle (not framework-managed). Two wrappers per SDK: + +- **`LocalStackContainer`** — Starts LocalStack, resolves + `LOCALSTACK_AUTH_TOKEN` from `secrets-map.json`, exposes SSM client and + provider. +- **`LowkeyVaultContainer`** — Starts Lowkey Vault (`nagyesta/lowkey-vault`), + configures `IDENTITY_ENDPOINT`/`IDENTITY_HEADER` env vars for + `DefaultAzureCredential`, exposes SecretClient and provider. + +### 2. Root `secrets-map.json` as Single Source of Truth + +All SDK test containers resolve `LOCALSTACK_AUTH_TOKEN` from the root +`secrets-map.json` at the repository root. There are no copies — each container +wrapper navigates to the root file via a relative path. This file: + +- Has `$config.profile` for local development (e.g., `"mac"`) +- Maps `LOCALSTACK_AUTH_TOKEN` to an SSM parameter path + (`/envilder/development/localstack/authToken`) +- **In CI**: Profile is ignored. AWS credentials come from OIDC + (`aws-actions/configure-aws-credentials`). The map file resolves the token + using the SDK's own `MapFileParser` + `EnvilderClient`. +- **In local dev**: Profile resolves credentials from `~/.aws/credentials`. +- **Fallback pattern**: If the configured provider (from `$config`) can't be + created (e.g., Azure credentials missing), fall back to AWS provider to + resolve the token. + +> **Note for contributors:** The `LOCALSTACK_AUTH_TOKEN` is a +> [LocalStack](https://localstack.cloud/) license token required for +> SSM SecureString support. The token stored in this project's SSM Parameter +> Store belongs to the project maintainer. If you want to run the AWS acceptance +> tests locally, you need your own LocalStack token — store it in AWS SSM (or +> Azure Key Vault) under a path of your choice, update your personal +> `secrets-map.json` profile and parameter path accordingly, and ensure your AWS +> credentials can resolve it. Without a valid token, LocalStack will start but +> SSM SecureString operations will fail. + +### 3. Lowkey Vault Configuration + +| Setting | Value | +| ----------- | ------------------------------------------------------------------------------ | +| Image | `nagyesta/lowkey-vault:7.1.61` (pinned) | +| Ports | 8443 (HTTPS), 8080 (HTTP token endpoint) | +| Args | `--server.port=8443 --LOWKEY_VAULT_RELAXED_PORTS=true` | +| Auth | `IDENTITY_ENDPOINT` set to Lowkey Vault token endpoint | +| TLS | Self-signed; tests must disable certificate verification | +| API version | Pinned per SDK (7.2 for .NET, 7.6 for Python) | +| Cleanup | Restore original `IDENTITY_ENDPOINT`/`IDENTITY_HEADER` values on teardown | + +### 4. CI Workflow Pattern + +- AWS OIDC credentials via `aws-actions/configure-aws-credentials` + (role-to-assume) +- `LOCALSTACK_AUTH_TOKEN` injected as `env` at job level (from + `secrets.LOCALSTACK_AUTH_TOKEN`) +- Docker socket override for TestContainers: + `TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock` +- Acceptance tests run alongside unit tests in the same job + +### 5. Test Directory Structure + +``` +tests/sdks/{lang}/ +├── containers/ ← Container wrappers +│ ├── localstack-container +│ └── lowkey-vault-container +├── acceptance/ ← Acceptance tests +│ ├── aws-ssm.acceptance.* +│ └── azure-key-vault.acceptance.* +└── {unit tests}/ +``` + +## Consequences + +### Positive + +- Consistent test infrastructure across all SDKs. Container lifecycle, token + resolution, and directory layout follow the same pattern regardless of + language. +- Self-documenting via shared `secrets-map.json`. The same file works for local + dev (AWS profile) and CI (OIDC) with no changes. +- Each SDK validates its own provider implementations against real emulators, + catching integration bugs that mocks would miss. + +### Negative + +- Container startup adds ~10-30s per test run. Mitigated: session-scoped + fixtures reuse containers across tests. + +## Implementations + +- **.NET**: `tests/sdks/dotnet/Fixtures/LocalStackFixture.cs`, + `tests/sdks/dotnet/Fixtures/LowkeyVaultFixture.cs` +- **Python**: `tests/sdks/python/containers/localstack_container.py`, + `tests/sdks/python/containers/lowkey_vault_container.py` +- **Node.js**: `tests/sdks/nodejs/containers/localstack-container.ts`, + `tests/sdks/nodejs/containers/lowkey-vault-container.ts` +- **Go**: Planned +- **Java**: Planned diff --git a/docs/changelogs/sdk-nodejs.md b/docs/changelogs/sdk-nodejs.md new file mode 100644 index 00000000..97533098 --- /dev/null +++ b/docs/changelogs/sdk-nodejs.md @@ -0,0 +1,16 @@ +## [0.1.0] - 2026-04-25 + +### Added + +* **Initial release** — Node.js runtime SDK for loading secrets directly + into `process.env` from a map file. Supports AWS SSM Parameter Store and + Azure Key Vault +* **Envilder facade** — `load()`, `resolveFile()`, `fromMapFile()` fluent + builder for one-liner or fine-grained secret loading +* **EnvilderClient** — Core resolver with `resolveSecrets()` and + `injectIntoEnvironment()` for custom provider usage +* **MapFileParser** — Parse `$config` section and variable mappings from JSON +* **Secret validation** — Opt-in `validateSecrets()` throws + `SecretValidationError` for empty or missing values +* **Environment-based routing** — Load different secret files per environment + (production, development, test) diff --git a/lefthook.yml b/lefthook.yml index 02bbfc84..befd3660 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -2,8 +2,10 @@ pre-commit: parallel: true commands: biome: - glob: "**/*.{ts,tsx,js,jsx,json}" - run: pnpx biome check --write --unsafe --no-errors-on-unmatched {staged_files} + glob: "**/*.{ts,tsx,js,jsx,json,jsonc,astro,mjs}" + run: >- + pnpx biome check --write --unsafe --no-errors-on-unmatched {staged_files} && + pnpx biome format --write --no-errors-on-unmatched {staged_files} stage_fixed: true dotnet-format: glob: "**/*.{cs,csproj}" diff --git a/package.json b/package.json index 89217820..7d1850b3 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,9 @@ "verify:gha": "pnpm build:gha && git diff --exit-code github-action/dist/index.js || (echo '❌ github-action/dist/index.js is not up to date. Run pnpm build:gha' && exit 1)", "local:install": "pnpm build && node --loader ts-node/esm scripts/pack-and-install.ts", "local:test-run": "pnpm build && node lib/envilder/apps/cli/Index.js --map=tests/sample/param-map.json --envfile=tests/sample/autogenerated.env", - "format": "biome format", - "format:write": "biome format --write", - "lint": "secretlint \"**/*\" && biome check --write && tsc --noEmit", + "format": "biome check --write --unsafe && biome format --write", + "format:check": "biome check && biome format", + "lint": "secretlint \"**/*\" && biome check --write --unsafe && tsc --noEmit", "lint:fix": "biome lint --fix", "test": "vitest run --reporter=verbose --coverage", "test:ci": "vitest run --reporter=verbose --reporter=junit --coverage --outputFile=coverage/junit/test-results.xml", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 436a8b5b..9f6f376d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,31 @@ importers: specifier: 'catalog:' version: 6.0.3 + src/sdks/nodejs: + dependencies: + '@aws-sdk/client-ssm': + specifier: ^3.700.0 + version: 3.1031.0 + '@aws-sdk/credential-providers': + specifier: ^3.700.0 + version: 3.1031.0 + '@azure/identity': + specifier: ^4.5.0 + version: 4.13.1 + '@azure/keyvault-secrets': + specifier: ^4.9.0 + version: 4.11.1 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 25.6.0 + rimraf: + specifier: ^6.0.0 + version: 6.1.3 + typescript: + specifier: 'catalog:' + version: 6.0.3 + src/website: dependencies: '@astrojs/sitemap': @@ -171,6 +196,36 @@ importers: specifier: 'catalog:' version: 6.0.3 + tests/sdks/nodejs: + devDependencies: + '@aws-sdk/client-ssm': + specifier: ^3.700.0 + version: 3.1031.0 + '@azure/identity': + specifier: ^4.5.0 + version: 4.13.1 + '@azure/keyvault-secrets': + specifier: ^4.9.0 + version: 4.11.1 + '@testcontainers/localstack': + specifier: ^11.13.0 + version: 11.14.0 + '@types/node': + specifier: 'catalog:' + version: 25.6.0 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.1.4(vitest@4.1.4) + testcontainers: + specifier: ^11.13.0 + version: 11.14.0 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + tests/website: devDependencies: typescript: @@ -3139,6 +3194,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rimraf@6.1.3: + resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} + engines: {node: 20 || >=22} + hasBin: true + rollup@4.60.1: resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -5589,7 +5649,7 @@ snapshots: '@types/sax@1.2.7': dependencies: - '@types/node': 24.12.2 + '@types/node': 25.6.0 '@types/ssh2-streams@0.1.13': dependencies: @@ -7543,6 +7603,11 @@ snapshots: reusify@1.1.0: {} + rimraf@6.1.3: + dependencies: + glob: 13.0.6 + package-json-from-dist: 1.0.1 + rollup@4.60.1: dependencies: '@types/estree': 1.0.8 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7ffe2f96..33f59cba 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,8 +2,10 @@ packages: - "." - "src/website" - "src/iac" + - "src/sdks/nodejs" - "tests/iac" - "tests/website" + - "tests/sdks/nodejs" catalog: '@biomejs/biome': ^2.4.9 diff --git a/src/sdks/nodejs/package.json b/src/sdks/nodejs/package.json new file mode 100644 index 00000000..37e55a05 --- /dev/null +++ b/src/sdks/nodejs/package.json @@ -0,0 +1,58 @@ +{ + "name": "@envilder/sdk", + "version": "0.1.0", + "description": "Load secrets from AWS SSM Parameter Store or Azure Key Vault directly into your Node.js process — no .env files needed.", + "license": "MIT", + "author": { + "name": "Marçal Albert Castellví", + "email": "mac.albert@gmail.com", + "url": "https://github.com/macalbert/envilder" + }, + "repository": { + "type": "git", + "url": "git://github.com/macalbert/envilder.git", + "directory": "src/sdks/nodejs" + }, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc", + "clean": "rimraf dist" + }, + "keywords": [ + "secrets", + "environment", + "aws", + "ssm", + "azure", + "keyvault", + "envilder" + ], + "engines": { + "node": ">=20" + }, + "dependencies": { + "@aws-sdk/client-ssm": "^3.700.0", + "@aws-sdk/credential-providers": "^3.700.0", + "@azure/identity": "^4.5.0", + "@azure/keyvault-secrets": "^4.9.0" + }, + "devDependencies": { + "@types/node": "catalog:", + "typescript": "catalog:", + "rimraf": "^6.0.0" + } +} \ No newline at end of file diff --git a/src/sdks/nodejs/src/application/envilder-client.ts b/src/sdks/nodejs/src/application/envilder-client.ts new file mode 100644 index 00000000..1331ee98 --- /dev/null +++ b/src/sdks/nodejs/src/application/envilder-client.ts @@ -0,0 +1,58 @@ +import type { ParsedMapFile } from '../domain/parsed-map-file.js'; +import type { ISecretProvider } from '../domain/ports/secret-provider.js'; + +/** + * Core client that resolves secrets from a configured provider. + * + * For most use cases prefer the {@link Envilder} facade. + * Use this class directly when you need a custom {@link ISecretProvider}. + * + * @example + * ```typescript + * const provider = new MyCustomProvider(); + * const mapFile = new MapFileParser().parse(json); + * const secrets = await new EnvilderClient(provider).resolveSecrets(mapFile); + * ``` + */ +export class EnvilderClient { + private readonly secretProvider: ISecretProvider; + + constructor(secretProvider: ISecretProvider) { + if (!secretProvider) { + throw new Error('secretProvider cannot be null'); + } + this.secretProvider = secretProvider; + } + + /** + * Resolves all mappings against the configured secret provider. + * Entries whose secret does not exist are silently omitted. + */ + async resolveSecrets(mapFile: ParsedMapFile): Promise> { + if (mapFile.mappings.size === 0) { + return new Map(); + } + + const secretPaths = Array.from(mapFile.mappings.values()); + const resolved = await this.secretProvider.getSecrets(secretPaths); + + const result = new Map(); + for (const [envVarName, secretPath] of mapFile.mappings) { + const value = resolved.get(secretPath); + if (value !== undefined) { + result.set(envVarName, value); + } + } + + return result; + } + + /** + * Sets every key/value pair as a process-level environment variable. + */ + static injectIntoEnvironment(secrets: Map): void { + for (const [key, value] of secrets) { + process.env[key] = value; + } + } +} diff --git a/src/sdks/nodejs/src/application/envilder.ts b/src/sdks/nodejs/src/application/envilder.ts new file mode 100644 index 00000000..bea0f8ca --- /dev/null +++ b/src/sdks/nodejs/src/application/envilder.ts @@ -0,0 +1,178 @@ +import { readFile } from 'node:fs/promises'; +import type { EnvilderOptions } from '../domain/envilder-options.js'; +import type { SecretProviderType } from '../domain/secret-provider-type.js'; +import { createSecretProvider } from '../infrastructure/secret-provider-factory.js'; +import { EnvilderClient } from './envilder-client.js'; +import { MapFileParser } from './map-file-parser.js'; + +/** + * Facade for loading secrets from cloud providers. + * + * Supports loading from a single map file or from an + * environment-based mapping that routes each environment name + * to its own map file (or `null` to skip). + * + * @example + * ```typescript + * // One-liner — resolve + inject into process.env: + * await Envilder.load('secrets-map.json'); + * + * // Resolve without injecting: + * const secrets = await Envilder.resolveFile('secrets-map.json'); + * + * // Fluent builder with provider override: + * const secrets = await Envilder.fromMapFile('secrets-map.json') + * .withProvider(SecretProviderType.Azure) + * .withVaultUrl('https://my-vault.vault.azure.net') + * .inject(); + * ``` + */ +export class Envilder { + private readonly filePath: string; + private readonly options: EnvilderOptions = {}; + + private constructor(filePath: string) { + this.filePath = filePath; + } + + /** + * Returns a fluent builder bound to the given map file. + * + * Chain `.withProvider()`, `.withVaultUrl()`, or `.withProfile()` + * before calling `.resolve()` or `.inject()`. + */ + static fromMapFile(filePath: string): Envilder { + validateFilePath(filePath); + return new Envilder(filePath.trim()); + } + + /** + * Resolves secrets and injects them into `process.env`. + * + * Can be called in two ways: + * - `load(filePath)` — load from a single map file + * - `load(env, envMapping)` — look up env in the mapping + */ + static async load( + filePathOrEnv: string, + envMapping?: Record, + ): Promise> { + if (envMapping !== undefined) { + const source = resolveEnvSource(filePathOrEnv, envMapping); + if (source === null) { + return new Map(); + } + return new Envilder(source).inject(); + } + + validateFilePath(filePathOrEnv); + return new Envilder(filePathOrEnv.trim()).inject(); + } + + /** + * Resolves secrets without injecting into `process.env`. + * + * Can be called in two ways: + * - `resolveFile(filePath)` — resolve from a single map file + * - `resolveFile(env, envMapping)` — look up env in the mapping + */ + static async resolveFile( + filePathOrEnv: string, + envMapping?: Record, + ): Promise> { + if (envMapping !== undefined) { + const source = resolveEnvSource(filePathOrEnv, envMapping); + if (source === null) { + return new Map(); + } + return new Envilder(source).resolve(); + } + + validateFilePath(filePathOrEnv); + return new Envilder(filePathOrEnv.trim()).resolve(); + } + + /** Override the secret provider (AWS or Azure). */ + withProvider(provider: SecretProviderType): Envilder { + this.options.provider = provider; + return this; + } + + /** Override the Azure Key Vault URL. */ + withVaultUrl(vaultUrl: string): Envilder { + this.options.vaultUrl = vaultUrl; + return this; + } + + /** Override the AWS named profile. */ + withProfile(profile: string): Envilder { + this.options.profile = profile; + return this; + } + + /** Resolve secrets and return them as a Map. */ + async resolve(): Promise> { + const mapFile = await this.parseFile(); + const options = this.buildOptions(); + const provider = createSecretProvider(mapFile.config, options); + const client = new EnvilderClient(provider); + return client.resolveSecrets(mapFile); + } + + /** Resolve secrets, inject into `process.env`, and return them. */ + async inject(): Promise> { + const secrets = await this.resolve(); + EnvilderClient.injectIntoEnvironment(secrets); + return secrets; + } + + private async parseFile() { + const json = await readFile(this.filePath, 'utf-8'); + return new MapFileParser().parse(json); + } + + private buildOptions(): EnvilderOptions | undefined { + const hasOverrides = + this.options.provider !== undefined || + this.options.vaultUrl !== undefined || + this.options.profile !== undefined; + return hasOverrides ? this.options : undefined; + } +} + +function validateFilePath(filePath: string): void { + if (!filePath?.trim()) { + throw new Error('file path cannot be empty'); + } +} + +function resolveEnvSource( + env: string, + envMapping: Record, +): string | null { + if (!env?.trim()) { + throw new Error('env cannot be empty'); + } + + const normalized = env.trim(); + + if (!(normalized in envMapping)) { + throw new Error( + `Environment '${normalized}' not found in environment mapping.`, + ); + } + + const source = envMapping[normalized]; + + if (source === null) { + return null; + } + + if (!source.trim()) { + throw new Error( + `env_mapping contains an empty file path for environment '${normalized}'.`, + ); + } + + return source.trim(); +} diff --git a/src/sdks/nodejs/src/application/map-file-parser.ts b/src/sdks/nodejs/src/application/map-file-parser.ts new file mode 100644 index 00000000..54e4c745 --- /dev/null +++ b/src/sdks/nodejs/src/application/map-file-parser.ts @@ -0,0 +1,55 @@ +import type { MapFileConfig } from '../domain/map-file-config.js'; +import type { ParsedMapFile } from '../domain/parsed-map-file.js'; +import { SecretProviderType } from '../domain/secret-provider-type.js'; + +const CONFIG_KEY = '$config'; + +const PROVIDER_MAP: Record = { + aws: SecretProviderType.Aws, + azure: SecretProviderType.Azure, +}; + +/** + * Parses a JSON map-file string into a {@link ParsedMapFile}. + */ +export class MapFileParser { + /** + * Parse a JSON map-file string, extracting `$config` and variable mappings. + * + * @param json - Raw JSON string of the map file. + * @returns Parsed config and variable mappings. + */ + parse(json: string): ParsedMapFile { + const raw = JSON.parse(json); + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + throw new Error('Invalid map file: root must be a JSON object'); + } + const mappings = new Map(); + let config: MapFileConfig = {}; + + for (const [key, value] of Object.entries(raw)) { + if (key === CONFIG_KEY && typeof value === 'object' && value !== null) { + const obj = value as Record; + const providerStr = + typeof obj.provider === 'string' + ? obj.provider.toLowerCase() + : undefined; + config = { + provider: providerStr ? PROVIDER_MAP[providerStr] : undefined, + vaultUrl: typeof obj.vaultUrl === 'string' ? obj.vaultUrl : undefined, + profile: typeof obj.profile === 'string' ? obj.profile : undefined, + }; + + if (providerStr && !config.provider) { + throw new Error( + `Unknown provider: '${obj.provider}'. Supported: aws, azure`, + ); + } + } else if (typeof value === 'string') { + mappings.set(key, value); + } + } + + return { config, mappings }; + } +} diff --git a/src/sdks/nodejs/src/application/secret-validation.ts b/src/sdks/nodejs/src/application/secret-validation.ts new file mode 100644 index 00000000..b9ea3f0c --- /dev/null +++ b/src/sdks/nodejs/src/application/secret-validation.ts @@ -0,0 +1,42 @@ +/** + * Thrown when resolved secrets contain missing or empty values. + */ +export class SecretValidationError extends Error { + /** + * Keys whose values were empty or whitespace-only. + * Empty array when no secrets were resolved at all. + */ + readonly missingKeys: string[]; + + constructor(missingKeys: string[]) { + super( + missingKeys.length === 0 + ? 'No secrets were resolved' + : `The following secrets have empty or missing values: ${missingKeys.join(', ')}`, + ); + this.missingKeys = missingKeys; + this.name = 'SecretValidationError'; + } +} + +/** + * Validates that all resolved secrets have non-empty values. + * + * @throws {SecretValidationError} When the map is empty or any value is empty/whitespace. + */ +export function validateSecrets(secrets: ReadonlyMap): void { + if (secrets.size === 0) { + throw new SecretValidationError([]); + } + + const missingKeys: string[] = []; + for (const [key, value] of secrets) { + if (!value?.trim()) { + missingKeys.push(key); + } + } + + if (missingKeys.length > 0) { + throw new SecretValidationError(missingKeys); + } +} diff --git a/src/sdks/nodejs/src/domain/envilder-options.ts b/src/sdks/nodejs/src/domain/envilder-options.ts new file mode 100644 index 00000000..07d1447c --- /dev/null +++ b/src/sdks/nodejs/src/domain/envilder-options.ts @@ -0,0 +1,7 @@ +import type { SecretProviderType } from './secret-provider-type.js'; + +export interface EnvilderOptions { + provider?: SecretProviderType; + vaultUrl?: string; + profile?: string; +} diff --git a/src/sdks/nodejs/src/domain/map-file-config.ts b/src/sdks/nodejs/src/domain/map-file-config.ts new file mode 100644 index 00000000..286f4e2f --- /dev/null +++ b/src/sdks/nodejs/src/domain/map-file-config.ts @@ -0,0 +1,7 @@ +import type { SecretProviderType } from './secret-provider-type.js'; + +export interface MapFileConfig { + readonly provider?: SecretProviderType; + readonly vaultUrl?: string; + readonly profile?: string; +} diff --git a/src/sdks/nodejs/src/domain/parsed-map-file.ts b/src/sdks/nodejs/src/domain/parsed-map-file.ts new file mode 100644 index 00000000..ff77b292 --- /dev/null +++ b/src/sdks/nodejs/src/domain/parsed-map-file.ts @@ -0,0 +1,6 @@ +import type { MapFileConfig } from './map-file-config.js'; + +export interface ParsedMapFile { + readonly config: MapFileConfig; + readonly mappings: ReadonlyMap; +} diff --git a/src/sdks/nodejs/src/domain/ports/secret-provider.ts b/src/sdks/nodejs/src/domain/ports/secret-provider.ts new file mode 100644 index 00000000..79b61d0a --- /dev/null +++ b/src/sdks/nodejs/src/domain/ports/secret-provider.ts @@ -0,0 +1,17 @@ +/** + * Abstracts access to a secret store (e.g. AWS SSM Parameter Store, Azure Key Vault). + * Implement this interface to add support for a new secret provider. + */ +export interface ISecretProvider { + /** + * Retrieves multiple secrets by their provider-specific identifiers. + * + * For AWS SSM these are parameter paths (e.g. `/app/db-url`); + * for Azure Key Vault these are secret names. + * + * Secrets that do not exist are silently omitted from the result. + * + * @returns A map of name → value for secrets that were found. + */ + getSecrets(names: string[]): Promise>; +} diff --git a/src/sdks/nodejs/src/domain/secret-provider-type.ts b/src/sdks/nodejs/src/domain/secret-provider-type.ts new file mode 100644 index 00000000..d4e1ba85 --- /dev/null +++ b/src/sdks/nodejs/src/domain/secret-provider-type.ts @@ -0,0 +1,4 @@ +export enum SecretProviderType { + Aws = 'aws', + Azure = 'azure', +} diff --git a/src/sdks/nodejs/src/index.ts b/src/sdks/nodejs/src/index.ts new file mode 100644 index 00000000..c5bcca69 --- /dev/null +++ b/src/sdks/nodejs/src/index.ts @@ -0,0 +1,19 @@ +// Domain + +// Application +export { Envilder } from './application/envilder.js'; +export { EnvilderClient } from './application/envilder-client.js'; +export { MapFileParser } from './application/map-file-parser.js'; +export { + SecretValidationError, + validateSecrets, +} from './application/secret-validation.js'; +export type { EnvilderOptions } from './domain/envilder-options.js'; +export type { MapFileConfig } from './domain/map-file-config.js'; +export type { ParsedMapFile } from './domain/parsed-map-file.js'; +export type { ISecretProvider } from './domain/ports/secret-provider.js'; +export { SecretProviderType } from './domain/secret-provider-type.js'; + +// Infrastructure (for advanced usage) +export { AwsSsmSecretProvider } from './infrastructure/aws/aws-ssm-secret-provider.js'; +export { AzureKeyVaultSecretProvider } from './infrastructure/azure/azure-key-vault-secret-provider.js'; diff --git a/src/sdks/nodejs/src/infrastructure/aws/aws-ssm-secret-provider.ts b/src/sdks/nodejs/src/infrastructure/aws/aws-ssm-secret-provider.ts new file mode 100644 index 00000000..d7ec6a08 --- /dev/null +++ b/src/sdks/nodejs/src/infrastructure/aws/aws-ssm-secret-provider.ts @@ -0,0 +1,55 @@ +import { GetParametersCommand, type SSMClient } from '@aws-sdk/client-ssm'; +import type { ISecretProvider } from '../../domain/ports/secret-provider.js'; + +const SSM_BATCH_SIZE = 10; + +/** + * {@link ISecretProvider} backed by AWS SSM Parameter Store. + * + * Parameters are retrieved with decryption enabled so that + * SecureString values are returned in plain text. + * + * SSM supports fetching up to 10 parameters per request, + * so names are chunked into batches automatically. + */ +export class AwsSsmSecretProvider implements ISecretProvider { + private readonly ssmClient: SSMClient; + + constructor(ssmClient: SSMClient) { + if (!ssmClient) { + throw new Error('ssmClient cannot be null'); + } + this.ssmClient = ssmClient; + } + + async getSecrets(names: string[]): Promise> { + const result = new Map(); + if (names.length === 0) { + return result; + } + + for (const name of names) { + if (!name?.trim()) { + throw new Error('Secret name cannot be null or whitespace'); + } + } + + for (let i = 0; i < names.length; i += SSM_BATCH_SIZE) { + const batch = names.slice(i, i + SSM_BATCH_SIZE); + const response = await this.ssmClient.send( + new GetParametersCommand({ + Names: batch, + WithDecryption: true, + }), + ); + + for (const param of response.Parameters ?? []) { + if (param.Name && param.Value) { + result.set(param.Name, param.Value); + } + } + } + + return result; + } +} diff --git a/src/sdks/nodejs/src/infrastructure/azure/azure-key-vault-secret-provider.ts b/src/sdks/nodejs/src/infrastructure/azure/azure-key-vault-secret-provider.ts new file mode 100644 index 00000000..e2e0d84a --- /dev/null +++ b/src/sdks/nodejs/src/infrastructure/azure/azure-key-vault-secret-provider.ts @@ -0,0 +1,68 @@ +import type { SecretClient } from '@azure/keyvault-secrets'; +import type { ISecretProvider } from '../../domain/ports/secret-provider.js'; + +/** + * {@link ISecretProvider} backed by Azure Key Vault. + * + * Secrets are fetched in parallel. Secrets that return HTTP 404 + * are treated as missing and silently omitted from the result. + */ +export class AzureKeyVaultSecretProvider implements ISecretProvider { + private readonly secretClient: SecretClient; + + constructor(secretClient: SecretClient) { + if (!secretClient) { + throw new Error('secretClient cannot be null'); + } + this.secretClient = secretClient; + } + + async getSecrets(names: string[]): Promise> { + const result = new Map(); + if (names.length === 0) { + return result; + } + + for (const name of names) { + if (!name?.trim()) { + throw new Error('Secret name cannot be null or empty'); + } + } + + const entries = await Promise.all( + names.map(async (name) => { + const value = await this.fetchSecret(name); + return [name, value] as const; + }), + ); + + for (const [name, value] of entries) { + if (value !== null) { + result.set(name, value); + } + } + + return result; + } + + private async fetchSecret(name: string): Promise { + try { + const response = await this.secretClient.getSecret(name); + return response.value ?? null; + } catch (error: unknown) { + if (isNotFound(error)) { + return null; + } + throw error; + } + } +} + +function isNotFound(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'statusCode' in error && + (error as { statusCode: number }).statusCode === 404 + ); +} diff --git a/src/sdks/nodejs/src/infrastructure/secret-provider-factory.ts b/src/sdks/nodejs/src/infrastructure/secret-provider-factory.ts new file mode 100644 index 00000000..9d61a4ad --- /dev/null +++ b/src/sdks/nodejs/src/infrastructure/secret-provider-factory.ts @@ -0,0 +1,52 @@ +import { SSMClient } from '@aws-sdk/client-ssm'; +import { fromIni } from '@aws-sdk/credential-providers'; +import { DefaultAzureCredential } from '@azure/identity'; +import { SecretClient } from '@azure/keyvault-secrets'; +import type { EnvilderOptions } from '../domain/envilder-options.js'; +import type { MapFileConfig } from '../domain/map-file-config.js'; +import type { ISecretProvider } from '../domain/ports/secret-provider.js'; +import { SecretProviderType } from '../domain/secret-provider-type.js'; +import { AwsSsmSecretProvider } from './aws/aws-ssm-secret-provider.js'; +import { AzureKeyVaultSecretProvider } from './azure/azure-key-vault-secret-provider.js'; + +export function createSecretProvider( + config: MapFileConfig, + options?: EnvilderOptions, +): ISecretProvider { + const provider = options?.provider ?? config.provider; + const profile = options?.profile ?? config.profile; + const vaultUrl = options?.vaultUrl ?? config.vaultUrl; + const isAzure = provider === SecretProviderType.Azure; + + if (isAzure && profile) { + throw new Error('AWS profile cannot be used with Azure Key Vault provider'); + } + + if (!isAzure && vaultUrl) { + throw new Error('Vault URL cannot be used with AWS SSM provider'); + } + + if (isAzure) { + return createAzureProvider(vaultUrl); + } + + return createAwsProvider(profile); +} + +function createAzureProvider( + vaultUrl: string | undefined, +): AzureKeyVaultSecretProvider { + if (!vaultUrl?.trim()) { + throw new Error('Vault URL must be provided for Azure Key Vault provider'); + } + + const credential = new DefaultAzureCredential(); + const client = new SecretClient(vaultUrl, credential); + return new AzureKeyVaultSecretProvider(client); +} + +function createAwsProvider(profile: string | undefined): AwsSsmSecretProvider { + const clientOptions = profile ? { credentials: fromIni({ profile }) } : {}; + const client = new SSMClient(clientOptions); + return new AwsSsmSecretProvider(client); +} diff --git a/src/sdks/nodejs/tsconfig.json b/src/sdks/nodejs/tsconfig.json new file mode 100644 index 00000000..45ab8769 --- /dev/null +++ b/src/sdks/nodejs/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/src/sdks/typescript/.gitkeep b/src/sdks/typescript/.gitkeep deleted file mode 100644 index fd0c34e0..00000000 --- a/src/sdks/typescript/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# Future home of the TypeScript SDK implementation \ No newline at end of file diff --git a/src/website/astro.config.mjs b/src/website/astro.config.mjs index c9f8a42b..26c69276 100644 --- a/src/website/astro.config.mjs +++ b/src/website/astro.config.mjs @@ -6,6 +6,38 @@ const rootPkg = JSON.parse( readFileSync(new URL('../../package.json', import.meta.url), 'utf-8'), ); +const sdkNodePkg = JSON.parse( + readFileSync( + new URL('../sdks/nodejs/package.json', import.meta.url), + 'utf-8', + ), +); + +function extractCsprojVersion(relativePath) { + try { + const xml = readFileSync(new URL(relativePath, import.meta.url), 'utf-8'); + const match = xml.match(/(.*?)<\/Version>/); + return match ? match[1] : '0.0.0'; + } catch { + return '0.0.0'; + } +} + +function extractPyprojectVersion(relativePath) { + try { + const toml = readFileSync(new URL(relativePath, import.meta.url), 'utf-8'); + const match = toml.match(/^version\s*=\s*"(.*?)"/m); + return match ? match[1] : '0.0.0'; + } catch { + return '0.0.0'; + } +} + +const sdkDotnetVersion = extractCsprojVersion('../sdks/dotnet/Envilder.csproj'); +const sdkPythonVersion = extractPyprojectVersion( + '../sdks/python/pyproject.toml', +); + let changelogContent = ''; try { changelogContent = readFileSync( @@ -28,6 +60,9 @@ const changelogCli = readChangelog('../../docs/changelogs/cli.md'); const changelogGha = readChangelog('../../docs/changelogs/gha.md'); const changelogSdkDotnet = readChangelog('../../docs/changelogs/sdk-dotnet.md'); const changelogSdkPython = readChangelog('../../docs/changelogs/sdk-python.md'); +const changelogSdkNodejs = readChangelog( + '../../docs/changelogs/sdk-nodejs.md', +); export default defineConfig({ site: 'https://envilder.com', @@ -48,6 +83,10 @@ export default defineConfig({ __CHANGELOG_GHA__: JSON.stringify(changelogGha), __CHANGELOG_SDK_DOTNET__: JSON.stringify(changelogSdkDotnet), __CHANGELOG_SDK_PYTHON__: JSON.stringify(changelogSdkPython), + __CHANGELOG_SDK_NODEJS__: JSON.stringify(changelogSdkNodejs), + __SDK_DOTNET_VERSION__: JSON.stringify(sdkDotnetVersion), + __SDK_PYTHON_VERSION__: JSON.stringify(sdkPythonVersion), + __SDK_NODEJS_VERSION__: JSON.stringify(sdkNodePkg.version), }, }, build: { diff --git a/src/website/public/sdk-logo.svg b/src/website/public/sdk-logo.svg new file mode 100644 index 00000000..ec5b3096 --- /dev/null +++ b/src/website/public/sdk-logo.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/website/public/sdk-typescript.svg b/src/website/public/sdk-typescript.svg deleted file mode 100644 index 6259bc9e..00000000 --- a/src/website/public/sdk-typescript.svg +++ /dev/null @@ -1 +0,0 @@ -TypeScript diff --git a/src/website/src/components/DocsContent.astro b/src/website/src/components/DocsContent.astro index 354c4a75..2a429d8b 100644 --- a/src/website/src/components/DocsContent.astro +++ b/src/website/src/components/DocsContent.astro @@ -10,6 +10,10 @@ interface Props { const { lang = 'en' } = Astro.props; const t = useTranslations(lang); +const sdkDotnetVersion = `v${__SDK_DOTNET_VERSION__}`; +const sdkPythonVersion = `v${__SDK_PYTHON_VERSION__}`; +const sdkNodejsVersion = `v${__SDK_NODEJS_VERSION__}`; + const azRbacAssign = `az role assignment create \\ --role "Key Vault Secrets Officer" \\ --assignee {YOUR_OBJECT_ID} \\ @@ -38,6 +42,7 @@ const sectionOrder = [ { id: 'push-single', label: t.docs.sidebarPushSingle }, { id: 'sdk-dotnet', label: t.docs.sidebarSdkDotnet }, { id: 'sdk-python', label: t.docs.sidebarSdkPython }, + { id: 'sdk-nodejs', label: t.docs.sidebarSdkNodejs }, { id: 'gha-setup', label: t.docs.sidebarGhaSetup }, { id: 'gha-basic', label: t.docs.sidebarGhaBasic }, { id: 'gha-multi-env', label: t.docs.sidebarGhaMultiEnv }, @@ -70,6 +75,7 @@ const sectionOrder = [ + @@ -109,6 +115,7 @@ const sectionOrder = [ @@ -504,7 +511,7 @@ envilder --push --provider=azure \\

{t.docs.sdkDotnetTitle}

{t.docs.sdkDotnetDesc}

{t.docs.sdkDotnetInstall}

@@ -583,7 +590,7 @@ EnvilderClient.InjectIntoEnvironment(secrets);`} />

{t.docs.sdkPythonTitle}

{t.docs.sdkPythonDesc}

{t.docs.sdkPythonInstall}

@@ -633,6 +640,70 @@ validate_secrets(secrets) # raises SecretValidationError if any value is empty`

{t.docs.sdkPythonFullDocs}

+ + +
+

{t.docs.sdkNodejsTitle}

+

{t.docs.sdkNodejsDesc}

+ + +

{t.docs.sdkNodejsInstall}

+ + +

{t.docs.sdkNodejsQuickStart}

+

{t.docs.sdkNodejsQuickStartDesc}

+ + +

{t.docs.sdkNodejsResolve}

+

{t.docs.sdkNodejsResolveDesc}

+ + +

{t.docs.sdkNodejsFluent}

+

{t.docs.sdkNodejsFluentDesc}

+ + +

{t.docs.sdkNodejsEnvLoading}

+

{t.docs.sdkNodejsEnvLoadingDesc}

+ + +

{t.docs.sdkNodejsValidation}

+

{t.docs.sdkNodejsValidationDesc}

+ + +

{t.docs.sdkNodejsFullDocs}

+
+
diff --git a/src/website/src/components/Sdks.astro b/src/website/src/components/Sdks.astro index 9a61633c..754e24af 100644 --- a/src/website/src/components/Sdks.astro +++ b/src/website/src/components/Sdks.astro @@ -75,17 +75,29 @@ secrets = ( )`, }, { - id: 'typescript', - title: t.sdks.typescriptTitle, - desc: t.sdks.typescriptDesc, - icon: '/sdk-typescript.svg', - install: '', - status: 'planned' as const, - pkg: '', - docsHash: '', + id: 'nodejs', + title: t.sdks.nodejsTitle, + desc: t.sdks.nodejsDesc, + icon: '/sdk-logo.svg', + install: 'npm install @envilder/sdk', + status: 'available' as const, + pkg: 'https://www.npmjs.com/package/@envilder/sdk', + docsHash: 'sdk-nodejs', codeLang: 'typescript', codeFile: 'config.ts', - code: '', + code: `import { Envilder, SecretProviderType } from '@envilder/sdk'; + +// One-liner: resolve + inject into process.env +await Envilder.load('param-map.json'); + +// Or resolve as a Map without injecting +const secrets = await Envilder.resolveFile('param-map.json'); + +// Fluent builder with provider override +const override = await Envilder.fromMapFile('param-map.json') + .withProvider(SecretProviderType.Azure) + .withVaultUrl('https://my-vault.vault.azure.net') + .inject();`, }, { id: 'go', diff --git a/src/website/src/env.d.ts b/src/website/src/env.d.ts index db85265d..9f85cae6 100644 --- a/src/website/src/env.d.ts +++ b/src/website/src/env.d.ts @@ -4,6 +4,10 @@ declare const __CHANGELOG_CLI__: string; declare const __CHANGELOG_GHA__: string; declare const __CHANGELOG_SDK_DOTNET__: string; declare const __CHANGELOG_SDK_PYTHON__: string; +declare const __CHANGELOG_SDK_NODEJS__: string; +declare const __SDK_DOTNET_VERSION__: string; +declare const __SDK_PYTHON_VERSION__: string; +declare const __SDK_NODEJS_VERSION__: string; interface Window { gtag?: (...args: unknown[]) => void; diff --git a/src/website/src/i18n/ca.ts b/src/website/src/i18n/ca.ts index 960f7792..3dd59559 100644 --- a/src/website/src/i18n/ca.ts +++ b/src/website/src/i18n/ca.ts @@ -286,8 +286,8 @@ export const ca: Translations = { dotnetTitle: '.NET', dotnetDesc: 'Integració nativa amb IConfiguration. Resol secrets a l’arrencada.', - typescriptTitle: 'TypeScript', - typescriptDesc: + nodejsTitle: 'Node.js', + nodejsDesc: 'Carrega secrets directament a Node.js. Mateix map-file, zero dependències del CLI.', goTitle: 'Go', goDesc: 'Càrrega lleugera de secrets per a serveis Go.', @@ -385,11 +385,11 @@ export const ca: Translations = { 'Biblioteca per a apps enterprise i Azure-native. Publicat a NuGet', }, { - status: 'next', - label: '📦', - title: 'TypeScript SDK (@envilder/sdk)', + status: 'done', + label: '✅', + title: 'Node.js SDK (@envilder/sdk)', description: - 'Biblioteca nativa de runtime — carregarà secrets directament a process.env des d’un map-file. Es publicarà a npm', + 'Biblioteca nativa de runtime — carrega secrets directament a process.env des d’un map-file. Publicat a npm', }, { status: 'next', @@ -494,6 +494,7 @@ export const ca: Translations = { categorySdks: 'SDKs', categorySdkDotnet: '.NET', categorySdkPython: 'Python', + categorySdkNodejs: 'Node.js', }, docs: { title: 'Docs Envilder | CLI, GitHub Action i AWS SSM', @@ -712,6 +713,7 @@ export const ca: Translations = { sidebarSdks: 'SDKs', sidebarSdkDotnet: '.NET SDK', sidebarSdkPython: 'Python SDK', + sidebarSdkNodejs: 'Node.js SDK', sdkDotnetTitle: '.NET SDK', sdkDotnetDesc: "Carrega secrets directament a la teva aplicació .NET a l'inici. Façana d'una línia, constructor fluent, integració amb IConfiguration o control programàtic total.", @@ -758,6 +760,26 @@ export const ca: Translations = { sdkPythonValidationDesc: 'Validació opcional que assegura que tots els secrets resolts tenen valors no buits:', sdkPythonFullDocs: 'Documentació completa →', + sdkNodejsTitle: 'Node.js SDK', + sdkNodejsDesc: + "Carrega secrets directament a la teva aplicació Node.js a l'inici. API asíncrona amb façana d'una línia o constructor fluent per a control total.", + sdkNodejsInstall: 'Instal·lació', + sdkNodejsQuickStart: 'Inici ràpid — una línia', + sdkNodejsQuickStartDesc: + "Carrega secrets des d'un fitxer de mapeig i injecta'ls a process.env:", + sdkNodejsResolve: 'Resoldre sense injectar', + sdkNodejsResolveDesc: + "Obté secrets com un Map sense modificar l'entorn:", + sdkNodejsFluent: 'Constructor fluent amb sobreescriptures', + sdkNodejsFluentDesc: + "Sobreescriu la configuració del proveïdor de manera programàtica amb l'API fluent:", + sdkNodejsEnvLoading: 'Càrrega basada en entorn', + sdkNodejsEnvLoadingDesc: + 'Recomanat per a aplicacions multi-entorn. Mapeja cada entorn al seu fitxer de secrets:', + sdkNodejsValidation: 'Validació de secrets', + sdkNodejsValidationDesc: + 'Validació opcional que assegura que tots els secrets resolts tenen valors no buits:', + sdkNodejsFullDocs: 'Documentació completa →', pagerPrev: 'Anterior', pagerNext: 'Següent', }, diff --git a/src/website/src/i18n/en.ts b/src/website/src/i18n/en.ts index bc085bb5..c6df0c80 100644 --- a/src/website/src/i18n/en.ts +++ b/src/website/src/i18n/en.ts @@ -286,8 +286,8 @@ export const en: Translations = { dotnetTitle: '.NET', dotnetDesc: 'Native IConfiguration integration. Resolve secrets at startup.', - typescriptTitle: 'TypeScript', - typescriptDesc: + nodejsTitle: 'Node.js', + nodejsDesc: 'Load secrets directly in Node.js. Same map-file, zero dependencies on the CLI.', goTitle: 'Go', goDesc: 'Lightweight secret loading for Go services.', @@ -382,9 +382,9 @@ export const en: Translations = { 'Runtime library for enterprise apps and Azure-native shops. Published to NuGet', }, { - status: 'next', - label: '📦', - title: 'TypeScript SDK (@envilder/sdk)', + status: 'done', + label: '✅', + title: 'Node.js SDK (@envilder/sdk)', description: 'Native runtime library — load secrets directly into process.env from a map-file. Published to npm', }, @@ -492,6 +492,7 @@ export const en: Translations = { categorySdks: 'SDKs', categorySdkDotnet: '.NET', categorySdkPython: 'Python', + categorySdkNodejs: 'Node.js', }, docs: { title: 'Envilder Docs | CLI, GitHub Action & AWS SSM', @@ -705,6 +706,7 @@ export const en: Translations = { sidebarSdks: 'SDKs', sidebarSdkDotnet: '.NET SDK', sidebarSdkPython: 'Python SDK', + sidebarSdkNodejs: 'Node.js SDK', sdkDotnetTitle: '.NET SDK', sdkDotnetDesc: 'Load secrets directly into your .NET application at startup. One-liner facade, fluent builder, IConfiguration integration, or full programmatic control.', @@ -751,6 +753,26 @@ export const en: Translations = { sdkPythonValidationDesc: 'Opt-in validation ensures all resolved secrets have non-empty values:', sdkPythonFullDocs: 'Full documentation →', + sdkNodejsTitle: 'Node.js SDK', + sdkNodejsDesc: + 'Load secrets directly into your Node.js application at startup. Async-first API with one-liner facade or fluent builder for full control.', + sdkNodejsInstall: 'Install', + sdkNodejsQuickStart: 'Quick start — one-liner', + sdkNodejsQuickStartDesc: + 'Load secrets from a map file and inject them into process.env:', + sdkNodejsResolve: 'Resolve without injecting', + sdkNodejsResolveDesc: + 'Get secrets as a Map without modifying the environment:', + sdkNodejsFluent: 'Fluent builder with overrides', + sdkNodejsFluentDesc: + 'Override provider settings programmatically using the fluent API:', + sdkNodejsEnvLoading: 'Environment-based loading', + sdkNodejsEnvLoadingDesc: + 'Recommended for multi-environment apps. Map each environment to its own secrets file:', + sdkNodejsValidation: 'Secret validation', + sdkNodejsValidationDesc: + 'Opt-in validation ensures all resolved secrets have non-empty values:', + sdkNodejsFullDocs: 'Full documentation →', pagerPrev: 'Previous', pagerNext: 'Next', }, diff --git a/src/website/src/i18n/es.ts b/src/website/src/i18n/es.ts index cf513810..b54a54ac 100644 --- a/src/website/src/i18n/es.ts +++ b/src/website/src/i18n/es.ts @@ -287,8 +287,8 @@ export const es: Translations = { dotnetTitle: '.NET', dotnetDesc: 'Integración nativa con IConfiguration. Resuelve secretos al arrancar.', - typescriptTitle: 'TypeScript', - typescriptDesc: + nodejsTitle: 'Node.js', + nodejsDesc: 'Carga secretos directamente en Node.js. Mismo map-file, cero dependencias del CLI.', goTitle: 'Go', goDesc: 'Carga ligera de secretos para servicios Go.', @@ -386,11 +386,11 @@ export const es: Translations = { 'Librería para apps enterprise y Azure-native. Publicado en NuGet', }, { - status: 'next', - label: '📦', - title: 'TypeScript SDK (@envilder/sdk)', + status: 'done', + label: '✅', + title: 'Node.js SDK (@envilder/sdk)', description: - 'Librería nativa de ejecución — cargará secretos directamente en process.env desde un map-file. Se publicará en npm', + 'Librería nativa de ejecución — carga secretos directamente en process.env desde un map-file. Publicado en npm', }, { status: 'next', @@ -495,6 +495,7 @@ export const es: Translations = { categorySdks: 'SDKs', categorySdkDotnet: '.NET', categorySdkPython: 'Python', + categorySdkNodejs: 'Node.js', }, docs: { title: 'Docs Envilder | CLI, GitHub Action y AWS SSM', @@ -712,6 +713,7 @@ export const es: Translations = { sidebarSdks: 'SDKs', sidebarSdkDotnet: '.NET SDK', sidebarSdkPython: 'Python SDK', + sidebarSdkNodejs: 'Node.js SDK', sdkDotnetTitle: '.NET SDK', sdkDotnetDesc: 'Carga secretos directamente en tu aplicación .NET al inicio. Fachada de una línea, constructor fluido, integración con IConfiguration o control programático total.', @@ -758,6 +760,26 @@ export const es: Translations = { sdkPythonValidationDesc: 'Validación opcional que asegura que todos los secretos resueltos tienen valores no vacíos:', sdkPythonFullDocs: 'Documentación completa →', + sdkNodejsTitle: 'Node.js SDK', + sdkNodejsDesc: + 'Carga secretos directamente en tu aplicación Node.js al inicio. API asíncrona con fachada de una línea o constructor fluido para control total.', + sdkNodejsInstall: 'Instalación', + sdkNodejsQuickStart: 'Inicio rápido — una línea', + sdkNodejsQuickStartDesc: + 'Carga secretos desde un archivo de mapeo e inyéctalos en process.env:', + sdkNodejsResolve: 'Resolver sin inyectar', + sdkNodejsResolveDesc: + 'Obtén secretos como un Map sin modificar el entorno:', + sdkNodejsFluent: 'Constructor fluido con sobreescrituras', + sdkNodejsFluentDesc: + 'Sobreescribe la configuración del proveedor de forma programática con la API fluida:', + sdkNodejsEnvLoading: 'Carga basada en entorno', + sdkNodejsEnvLoadingDesc: + 'Recomendado para aplicaciones multi-entorno. Mapea cada entorno a su archivo de secretos:', + sdkNodejsValidation: 'Validación de secretos', + sdkNodejsValidationDesc: + 'Validación opcional que asegura que todos los secretos resueltos tienen valores no vacíos:', + sdkNodejsFullDocs: 'Documentación completa →', pagerPrev: 'Anterior', pagerNext: 'Siguiente', }, diff --git a/src/website/src/i18n/types.ts b/src/website/src/i18n/types.ts index dfc618ef..2a8a60c8 100644 --- a/src/website/src/i18n/types.ts +++ b/src/website/src/i18n/types.ts @@ -156,8 +156,8 @@ export interface SdksTranslations { pythonDesc: string; dotnetTitle: string; dotnetDesc: string; - typescriptTitle: string; - typescriptDesc: string; + nodejsTitle: string; + nodejsDesc: string; goTitle: string; goDesc: string; javaTitle: string; @@ -263,6 +263,7 @@ export interface ChangelogPageTranslations { categorySdks: string; categorySdkDotnet: string; categorySdkPython: string; + categorySdkNodejs: string; } export interface DocsTranslations { @@ -463,6 +464,7 @@ export interface DocsTranslations { sidebarSdks: string; sidebarSdkDotnet: string; sidebarSdkPython: string; + sidebarSdkNodejs: string; sdkDotnetTitle: string; sdkDotnetDesc: string; sdkDotnetInstall: string; @@ -495,6 +497,20 @@ export interface DocsTranslations { sdkPythonValidation: string; sdkPythonValidationDesc: string; sdkPythonFullDocs: string; + sdkNodejsTitle: string; + sdkNodejsDesc: string; + sdkNodejsInstall: string; + sdkNodejsQuickStart: string; + sdkNodejsQuickStartDesc: string; + sdkNodejsResolve: string; + sdkNodejsResolveDesc: string; + sdkNodejsFluent: string; + sdkNodejsFluentDesc: string; + sdkNodejsEnvLoading: string; + sdkNodejsEnvLoadingDesc: string; + sdkNodejsValidation: string; + sdkNodejsValidationDesc: string; + sdkNodejsFullDocs: string; // Pager pagerPrev: string; pagerNext: string; diff --git a/src/website/src/pages/ca/changelog.astro b/src/website/src/pages/ca/changelog.astro index f0006d8b..ffa3d6fb 100644 --- a/src/website/src/pages/ca/changelog.astro +++ b/src/website/src/pages/ca/changelog.astro @@ -29,6 +29,12 @@ const products = [ raw: __CHANGELOG_SDK_PYTHON__ || fallback, group: 'sdks', }, + { + id: 'sdk-nodejs', + label: t.changelogPage.categorySdkNodejs, + raw: __CHANGELOG_SDK_NODEJS__ || fallback, + group: 'sdks', + }, { id: 'gha', label: t.changelogPage.categoryGha, @@ -136,6 +142,28 @@ const parsed = products.map((p) => ({ ))} + {/* TypeScript */} + + + {/* GHA */} + {/* TypeScript */} + + + {/* GHA */} + {/* TypeScript */} + + + {/* GHA */}