From 306b930f128b2e53c61ee8232b35b67f7f7e57c9 Mon Sep 17 00:00:00 2001 From: Abraham Date: Sat, 24 Oct 2026 08:48:03 +0300 Subject: [PATCH 1/2] fix: align Docker and scaffold environment setup --- .env.schema | 50 ++++++++++++++++ .gitignore | 1 + CONTRIBUTING.md | 2 +- docker-compose.yml | 22 ++++--- docs/cli.md | 8 +-- docs/configuration.md | 6 +- docs/deploy.md | 2 +- docs/getting-started.md | 14 ++--- package.json | 25 ++++---- packages/create-keyforge/README.md | 6 +- packages/create-keyforge/src/generate.mjs | 66 ++++++++++++++------ packages/create-keyforge/src/index.mjs | 4 +- packages/create-keyforge/src/platforms.mjs | 10 +++- pnpm-lock.yaml | 70 ++++++++++++++-------- scripts/init-secrets.mjs | 27 ++++----- scripts/local-env.mjs | 6 +- scripts/package-smoke.mjs | 2 +- scripts/runtime-package.mjs | 2 +- scripts/storage-init.mjs | 2 +- src/env.ts | 48 +++++++-------- test/create-keyforge.test.ts | 18 ++++-- test/secret-setup.test.ts | 43 +++++++------ 22 files changed, 277 insertions(+), 157 deletions(-) create mode 100644 .env.schema diff --git a/.env.schema b/.env.schema new file mode 100644 index 0000000..1e6c7d1 --- /dev/null +++ b/.env.schema @@ -0,0 +1,50 @@ +# Keyforge local environment. Run `vp run secrets:init --yes` before `vp run env:check`. +# @defaultRequired=false + +# @sensitive @required +LICENSE_SIGNING_KEY= + +# @sensitive +LICENSE_DATA_KEYRING_FILE= + +# @sensitive +LICENSE_BACKUP_KEYRING_FILE= + +# @sensitive +LICENSE_ADMIN_TOKEN= + +# @sensitive +LICENSE_AUTH_SECRET= + +# @sensitive +CRON_SECRET= + +# @sensitive +DATABASE_URL= + +# Docker-only URL. Use postgres:5432, not the host-facing port. +# @sensitive +COMPOSE_DATABASE_URL= + +LICENSE_DB_PATH= + +# @type=number +LICENSE_PORT=4100 + +# @type=number +POSTGRES_PORT=55433 + +# Docker Compose defaults to keyforge when unset. +POSTGRES_DB=keyforge + +# Docker Compose defaults to keyforge when unset; set a unique value for deployments. +# @sensitive +POSTGRES_PASSWORD= + +LICENSE_SERVER_URL=http://localhost:4100/ + +# Optional boot-time administrator. +LICENSE_SEED_ADMIN_EMAIL= + +# @sensitive +LICENSE_SEED_ADMIN_PASSWORD= diff --git a/.gitignore b/.gitignore index 96af507..e618c28 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ license-signing.key .env .env.* !.env.example +!.env.schema secrets/ backups/ *.keyring.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7acb9f5..72b3cea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ documented recipes are preferred over new required machinery. ```bash vp install -cp .env.example .env.local # fill in secrets first +cp .env.example .env # fill in secrets first vp run build # CLI imports compiled server modules vp run license keygen # local signing key vp run secrets:init diff --git a/docker-compose.yml b/docker-compose.yml index ce3c663..b78f0bd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,14 +5,19 @@ services: - "4100:4100" volumes: - keyforge-data:/data + - ./secrets:/app/secrets:ro # Uncomment to supply a deployment config: # - ./keyforge.config.json:/app/keyforge.config.json:ro environment: # Unset = SQLite in the volume. Point at the postgres service below # (docker compose --profile postgres up) for a durable ledger. - DATABASE_URL: ${DATABASE_URL:-} + # Set COMPOSE_DATABASE_URL for container-to-container Postgres access. + # DATABASE_URL can keep its host-facing localhost URL for local CLI commands. + DATABASE_URL: ${COMPOSE_DATABASE_URL:-${DATABASE_URL:-}} # Required: PEM private key (or mount a file and use LICENSE_SIGNING_KEY_FILE). LICENSE_SIGNING_KEY: ${LICENSE_SIGNING_KEY:?set in .env} + LICENSE_DATA_KEYRING_FILE: ${LICENSE_DATA_KEYRING_FILE:-./secrets/data.keyring.json} + LICENSE_BACKUP_KEYRING_FILE: ${LICENSE_BACKUP_KEYRING_FILE:-./secrets/backup.keyring.json} # Optional surfaces — each is disabled when unset. LICENSE_ADMIN_TOKEN: ${LICENSE_ADMIN_TOKEN:-} LICENSE_AUTH_SECRET: ${LICENSE_AUTH_SECRET:-} @@ -22,23 +27,24 @@ services: # Optional durable ledger: # docker compose --profile postgres up - # DATABASE_URL=postgres://keyforge:keyforge@postgres:5432/keyforge + # COMPOSE_DATABASE_URL=postgres://keyforge:keyforge@postgres:5432/keyforge?sslmode=disable postgres: - # Keep the existing data-directory major; upgrading to 18 requires pg_upgrade. - image: ${POSTGRES_IMAGE:-postgres:16.15} + image: ${POSTGRES_IMAGE:-postgres:18.6-bookworm} profiles: ["postgres"] environment: POSTGRES_USER: keyforge POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-keyforge} - POSTGRES_DB: keyforge + POSTGRES_DB: ${POSTGRES_DB:-keyforge} + ports: + - ${POSTGRES_PORT:-55433}:5432 volumes: - - keyforge-pgdata:/var/lib/postgresql/data + - keyforge-pgdata-v18:/var/lib/postgresql healthcheck: - test: ["CMD-SHELL", "pg_isready -U keyforge"] + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] interval: 10s timeout: 5s retries: 5 volumes: keyforge-data: - keyforge-pgdata: + keyforge-pgdata-v18: diff --git a/docs/cli.md b/docs/cli.md index 69c241f..c46fccd 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -6,7 +6,7 @@ Set `LICENSE_SERVER_URL` and `LICENSE_ADMIN_TOKEN` for API commands. Use `--prod | Command | Purpose | | --- | --- | -| `vp run license keygen` | Generate startup Ed25519 keys under secrets/ (or LICENSE_SIGNING_KEY_FILE) and save the path to the selected environment file, .env.local by default. Existing setup asks [Y/n]; --yes confirms automation. --force permits explicit key replacement and can invalidate client trust. | +| `vp run license keygen` | Generate startup Ed25519 keys under secrets/ (or LICENSE_SIGNING_KEY_FILE) and save the path to the selected environment file, .env by default. Existing setup asks [Y/n]; --yes confirms automation. --force permits explicit key replacement and can invalidate client trust. | | `vp run license keycheck` | Check configured private key, public fingerprint; --expect PEM/file, --token token, --server compare deployment. | | `vp run license issue` | Issue from --plan ID with --to name, optional --installation ID or --subject installation/service, and a stable --request-id UUID. Add --paid --method METHOD --reference ID to record full payment. Terms come from the saved plan. | | `vp run license list` | First page of selected product licenses. | @@ -20,7 +20,7 @@ Set `LICENSE_SERVER_URL` and `LICENSE_ADMIN_TOKEN` for API commands. Use `--prod | `vp run db mirror` | Copy the configured active ledger to --to file/URL. | | `vp run db verify` | Compare --from and --to engine-neutral row contents. | | `vp run admin:create` | Create an operator with --email and --password; --name and --role admin/support are optional. | -| `vp run secrets:init` | Initialize missing signing, data/backup encryption, admin, auth and cron secrets; save references/values to .env.local. Use --env-file PATH for an isolated named environment and --secrets-dir PATH for its key files. Existing setup asks [Y/n]; --yes confirms automation. Existing secrets are retained. | +| `vp run secrets:init` | Initialize missing signing, data/backup encryption, admin, auth and cron secrets; save values and keyring references to .env. Use --env-file PATH for an isolated named environment and --secrets-dir PATH for its key files. Existing setup asks [Y/n]; --yes confirms automation. Existing secrets are retained. | | `vp run secrets:export` | With --env-file PATH and --output PATH, write a private provider-neutral environment file embedding existing key files. No key rotation; provider identity variables are omitted. Existing output requires confirmation. | | `vp run backup:restore` | Explicit maintenance-only restore of an encrypted archive; see backups guide. | @@ -56,7 +56,7 @@ vp run storage:init The default app reset drops and recreates Keyforge ledger and operator tables, then recreates the configured bootstrap admin. SQLite database mode removes and recreates the single selected unified database file. SQLite resets refuse unrelated tables and paths outside the project. data:clean removes obsolete Keyforge SQLite files under db/database and UUID .kfbackup files in the configured backup directory; it retains active databases, unrelated databases and key files. It leaves remote uploads and backup history intact, so standalone cleanup can remove local-only download copies. demo:reset combines reset, local cleanup and seed. These offline commands differ from the account-preserving **Settings → System** reset; see [database maintenance](database.md). -Demo seed creates fictional products, active/expired/revoked licenses, registered and trial machines, support conversations, metered usage and admin/support operators. Random demo login credentials and client fixture IDs are written to the private, ignored .artifacts/demo/manifest.json file. The stored seed manifest is encrypted using the data ring. Completed seeds are idempotent; interrupted seeds require a reset before retrying. Existing configured bootstrap credentials are retained separately in .env.local. Demo sign-up is enabled only inside the unserved seeding process; the running app still disables public sign-up. +Demo seed creates fictional products, active/expired/revoked licenses, registered and trial machines, support conversations, metered usage and admin/support operators. Random demo login credentials and client fixture IDs are written to the private, ignored .artifacts/demo/manifest.json file. The stored seed manifest is encrypted using the data ring. Completed seeds are idempotent; interrupted seeds require a reset before retrying. Existing configured bootstrap credentials are retained separately in .env. Demo sign-up is enabled only inside the unserved seeding process; the running app still disables public sign-up. Postgres app mode preserves unrelated tables and fails if external dependencies prevent dropping app tables. For a dedicated app schema, use `vp run db:reset --scope schema --confirm SCHEMA_NAME --apply --maintenance`; unrelated objects or cross-schema foreign keys/views prevent a schema reset. Schema mode recreates the owner but not custom grants, which must be reapplied. @@ -64,7 +64,7 @@ For a whole Postgres database, set LICENSE_DATABASE_ADMIN_URL to a privileged co Reset removes online licensing records, operators, sessions and trial history. It does not rotate signing keys or invalidate already-issued offline tokens cryptographically; client expiry and online refresh still determine their remaining validity. -storage:init creates a uniquely named private AWS S3 and/or R2 bucket when a configured provider lacks a bucket, then writes its canonical setting to .env.local. Existing configured buckets are verified instead of recreated. Use --provider s3/r2/all, --name keyforge-storage-YOUR_UNIQUE_SUFFIX or --env-file PATH as needed. Setup needs bucket-creation permission in addition to normal backup permissions. S3 Block Public Access stays enabled. Provider request/storage charges apply to real uploads; configure independent schedules in Backups. +storage:init creates a uniquely named private AWS S3 and/or R2 bucket when a configured provider lacks a bucket, then writes its canonical setting to .env. Existing configured buckets are verified instead of recreated. Use --provider s3/r2/all, --name keyforge-storage-YOUR_UNIQUE_SUFFIX or --env-file PATH as needed. Setup needs bucket-creation permission in addition to normal backup permissions. S3 Block Public Access stays enabled. Provider request/storage charges apply to real uploads; configure independent schedules in Backups. ## Database setup and demo reset diff --git a/docs/configuration.md b/docs/configuration.md index 3ee3af6..a05434d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,6 @@ # Configuration and environment -For a complete first setup, follow the [beginner guide](getting-started.md). Copy `.env.example` to `.env.local` before provisioning secrets, replace its placeholders, and never commit the resulting file. A placeholder that happens to pass length validation is not a secure secret. +For a complete first setup, follow the [beginner guide](getting-started.md). Copy `.env.example` to `.env` before provisioning secrets, replace its placeholders, and never commit the resulting file. A placeholder that happens to pass length validation is not a secure secret. ## Database selection @@ -72,14 +72,14 @@ Use the reset, cleanup, demo seed and bucket setup commands in the [CLI guide](c ## Minimal environment and database-managed storage -Run `vp run secrets:init` to fill missing local secrets and file references in `.env.local`; existing setup requires confirmation. The default private key path is `secrets/license-signing.key`. Never configure both inline and file values for the same key. +Run `vp run secrets:init` to fill missing local secrets and keyring file references in `.env`; existing setup requires confirmation. The signing key defaults to an inline `LICENSE_SIGNING_KEY` value. Never configure both inline and file values for the same key. ## Portable environments and recovery files Application credentials are independent of the hosting provider. Use one private environment directory for each deployment, and keep provider access tokens separate: ```text -.env.local # local development +.env # local development secrets/license-signing.key # existing local signing key secrets/data.keyring.json # local stored-data encryption secrets/backup.keyring.json # local archive encryption diff --git a/docs/deploy.md b/docs/deploy.md index ade7fd6..d93dbad 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -25,7 +25,7 @@ See [Vercel deployment](vercel.md) for a single project with `main` production a The Dockerfile builds with Vite+, installs only production dependencies into the runtime, includes compatible Postgres dump/restore tools, and starts the compiled Node server. Keep secrets outside the image. Build via docker build -t keyforge . or use docker compose up --build. Ports, volumes and secret mounts are infrastructure commands, not package-manager commands. -The existing repository compose file keeps Postgres 16 on its latest supported patch to preserve existing major-version data compatibility. POSTGRES_IMAGE can override it only after a deliberate database upgrade. New scaffolds use a supported pinned major. Never switch a populated Postgres volume to another major image without pg_upgrade or dump/restore. +The repository compose file and new scaffolds use Postgres 18 with a volume mounted at `/var/lib/postgresql`. The repository uses a new `keyforge-pgdata-v18` volume, leaving any old Postgres 16 volume untouched. To move data from an older volume, use `pg_upgrade` or dump/restore; changing the image alone does not migrate it. For SQLite, mount the ledger/auth directory, secrets and backup directory persistently. For Postgres, provide DATABASE_URL and a durable external database; local database paths are ignored. Mount key-ring files persistently in either case. Backup providers can run concurrently; configure S3, R2 and UploadThing credentials through server secrets, and custom upload URLs in the console. Keep LICENSE_BACKUP_DIR persistent (shared across replicas for custom-endpoint downloads). Each provider has an independent daily UTC schedule; see [backups](backups.md). diff --git a/docs/getting-started.md b/docs/getting-started.md index 1cfdfaa..8a02410 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -17,13 +17,13 @@ The snippets use a POSIX shell unless labelled PowerShell. `vp` and `node` comma git clone https://github.com/zdevzone/keyforge.git cd keyforge vp install --frozen-lockfile -cp .env.example .env.local +cp .env.example .env ``` In PowerShell, replace the last command with: ```powershell -Copy-Item .env.example .env.local +Copy-Item .env.example .env ``` Do not overwrite an existing deployment's environment or keys. The [scaffolder](../packages/create-keyforge/README.md) generates independent deployment secrets with `vp create keyforge@latest`. The source-checkout procedure above also lets you run the server directly. @@ -55,20 +55,20 @@ Run this once from the project root (a pre-existing environment file is optional vp run secrets:init ``` -This creates an Ed25519 private key at `secrets/license-signing.key`, independent data and backup keyrings, an administrative API token, a session secret and a cron secret. It appends the matching settings to the ignored `.env.local` file without printing secret values. The public verification key may be shared with dependent apps. Configure your deployment URL, database and administrator separately; provider credentials are entered in **Settings → Backups**. +This creates an Ed25519 private key as `LICENSE_SIGNING_KEY` in the ignored `.env` file, independent data and backup keyrings, an administrative API token, a session secret and a cron secret. It writes settings without printing secret values. The public verification key may be shared with dependent apps. Configure your deployment URL, database and administrator separately; provider credentials are entered in **Settings → Backups**. For a separate deployment, use `vp run secrets:init --env-file secrets/environments/staging/server.env` instead of copying another environment's credentials. Export inline settings for any host with `vp run secrets:export --env-file secrets/environments/staging/server.env --output secrets/environments/staging/deploy.env`. See [portable environments](configuration.md#portable-environments-and-recovery-files). When moving an existing deployment, restore its original keys rather than generating new ones. On an existing setup, confirm the **[Y/n]** prompt before the file is updated. Existing keys and configured secrets are retained. For reviewed, noninteractive automation, pass `--yes`; a pipe without that flag cannot silently change an existing environment file. Recognized example placeholders are replaced, while real configured values are retained. -Keep `vp run license keygen` for explicitly generating a signing key, or `vp run license keygen --force` for a deliberate replacement. It also saves `LICENSE_SIGNING_KEY_FILE` in `.env.local`, asks before updating an existing file, and refuses to replace an existing key without `--force`. An inline signing key must be removed explicitly before switching to a newly generated file. Rotation changes client trust; distribute public keys deliberately. Never rotate production keys just to complete setup. +Keep `vp run license keygen` for explicitly generating a signing key, or `vp run license keygen --force` for a deliberate replacement. It also saves `LICENSE_SIGNING_KEY_FILE` in `.env`, asks before updating an existing file, and refuses to replace an existing key without `--force`. An inline signing key must be removed explicitly before switching to a newly generated file. Rotation changes client trust; distribute public keys deliberately. Never rotate production keys just to complete setup. ## Configure a local server -Edit `.env.local`, retaining your actual generated secrets: +Edit `.env`, retaining your actual generated secrets: ```dotenv -LICENSE_SIGNING_KEY_FILE=./secrets/license-signing.key +LICENSE_SIGNING_KEY= LICENSE_AUTH_SECRET= LICENSE_ADMIN_TOKEN= LICENSE_DATA_KEYRING_FILE=./secrets/data.keyring.json @@ -150,7 +150,7 @@ Other languages can use REST directly. Safe offline support additionally require ## Deploy to production -Follow the [deployment guide](deploy.md) for your platform. Transfer secrets using its secret manager or durable mounts; never put `.env.local`, private keys, database files or encryption rings in Git or a container image. +Follow the [deployment guide](deploy.md) for your platform. Transfer secrets using its secret manager or durable mounts; never put `.env`, private keys, database files or encryption rings in Git or a container image. On **Render**, use the repository's Blueprint or its documented build/start commands, rather than an old Yarn command. If `LICENSE_SERVER_URL` is unset, KeyForge uses Render's `RENDER_EXTERNAL_URL`. For a custom domain, set `LICENSE_SERVER_URL` to that HTTPS origin. Leave `LICENSE_PORT` unset to honor Render's assigned `PORT`. Remove the local HTTP origin and local port override when configuring production. diff --git a/package.json b/package.json index 3cdbc1f..13befe8 100644 --- a/package.json +++ b/package.json @@ -25,40 +25,40 @@ "type": "module", "scripts": { "secrets:init": "node -- scripts/init-secrets.mjs", + "env:check": "varlock load --agent", "secrets:export": "node -- scripts/export-secrets.mjs", - "license": "node --env-file-if-exists=.env.local -- scripts/license-tool.mjs", - "db": "node --env-file-if-exists=.env.local scripts/db-tool.mjs", - "admin:create": "node --env-file-if-exists=.env.local scripts/create-admin.mjs", + "license": "node --env-file-if-exists=.env -- scripts/license-tool.mjs", + "db": "node --env-file-if-exists=.env scripts/db-tool.mjs", + "admin:create": "node --env-file-if-exists=.env scripts/create-admin.mjs", "build": "tsc -p tsconfig.json && vp -C packages/keyforge-client run build && node scripts/runtime-package.mjs", "build:ui": "vp -C ui run build", "build:all": "vp run build && vp -C ui run build", "dev:ui": "vp -C ui dev", - "start": "node --env-file-if-exists=.env.local dist/src/index.js", + "start": "node --env-file-if-exists=.env dist/src/index.js", "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit && vp -C packages/keyforge-client run typecheck", "lint": "vp lint", "fmt": "vp fmt", "check": "vp lint && vp fmt --check && vp run typecheck && vp -C ui run typecheck", "test": "tsx --test \"test/**/*.test.ts\"", "test:postgres": "tsx --test --test-concurrency=1 \"test/**/*.test.ts\"", - "backup:restore": "node --env-file-if-exists=.env.local scripts/restore-backup.mjs", + "backup:restore": "node --env-file-if-exists=.env scripts/restore-backup.mjs", "test:console": "node scripts/console-smoke.mjs", "test:issuance": "node scripts/issuance-smoke.mjs", "test:docs": "node scripts/docs-smoke.mjs", "test:packages": "node scripts/package-smoke.mjs", "docs:api": "node scripts/generate-api-docs.mjs", "docs:client-api": "tsx scripts/generate-client-openapi.ts", - "db:reset": "node --env-file-if-exists=.env.local scripts/reset-database.mjs", - "data:clean": "node --env-file-if-exists=.env.local scripts/cleanup-data.mjs", - "demo:seed": "node --env-file-if-exists=.env.local scripts/demo-seed.mjs", - "demo:reset": "node --env-file-if-exists=.env.local scripts/reset-database.mjs --demo --clean-local", - "storage:init": "node --env-file-if-exists=.env.local scripts/storage-init.mjs", - "test:features": "node --env-file-if-exists=.env.local scripts/test-features.mjs" + "db:reset": "node --env-file-if-exists=.env scripts/reset-database.mjs", + "data:clean": "node --env-file-if-exists=.env scripts/cleanup-data.mjs", + "demo:seed": "node --env-file-if-exists=.env scripts/demo-seed.mjs", + "demo:reset": "node --env-file-if-exists=.env scripts/reset-database.mjs --demo --clean-local", + "storage:init": "node --env-file-if-exists=.env scripts/storage-init.mjs", + "test:features": "node --env-file-if-exists=.env scripts/test-features.mjs" }, "dependencies": { "@aws-sdk/client-s3": "^3.1149.0", "@fastify/rate-limit": "^11.2.1", "@fastify/static": "^10.1.6", - "@t3-oss/env-core": "^0.13.11", "@vercel/blob": "^2.8.2", "@vercel/functions": "^3.10.1", "better-auth": "^1.7.7", @@ -67,6 +67,7 @@ "kysely": "^0.29.6", "pg": "^8.23.1", "uploadthing": "^7.7.4", + "varlock": "^1.23.0", "zod": "^4.6.5" }, "devDependencies": { diff --git a/packages/create-keyforge/README.md b/packages/create-keyforge/README.md index 9367f33..7c7fc2d 100644 --- a/packages/create-keyforge/README.md +++ b/packages/create-keyforge/README.md @@ -18,6 +18,8 @@ vp create keyforge -- my-license-server --deploy docker --db postgres --pm vp -- Follow the printed steps to install dependencies, generate the signing key, build, create the first operator and start the server. See the [beginner guide](../../docs/getting-started.md) for credential purposes and your first license workflow. +The generated project includes a Varlock `.env.schema`; run `vp run env:check` after `vp run secrets:init --yes` to validate its local `.env`. See [Varlock's introduction](https://varlock.dev/getting-started/introduction/). + ## Run from a source checkout To use the generator directly from the repository: @@ -34,7 +36,7 @@ Or run a noninteractive source-checkout deployment: vp node packages/create-keyforge/bin/create-keyforge.mjs ../my-license-server --template . --deploy docker --db postgres --pm vp --runtime node --yes ``` -Set `DATABASE_URL` in the generated `.env.local` before starting a PostgreSQL deployment. +Set `DATABASE_URL` in the generated `.env` before starting a PostgreSQL deployment. You can also [use this template](https://github.com/new?template_name=keyforge&template_owner=zdevzone) for a source copy. Maintainers: see [package releases](../../docs/publishing.md) for archive checks and release order. @@ -54,7 +56,7 @@ Run `node packages/create-keyforge/bin/create-keyforge.mjs --help` for the full ## Generated project -The generator removes upstream identity, local databases, dependency folders, build output and secret files. Protect the generated `.env.local` and `secrets/` directory. Select trusted templates: they supply executable project sources. +The generator removes upstream identity, local databases, dependency folders, build output and secret files. Protect the generated `.env` and `secrets/` directory. Select trusted templates: they supply executable project sources. Manage products, entitlement definitions and priced plans in the console. The root configuration seeds the initial catalog once; later console changes live in the database. Issuance snapshots the selected plan's terms and price into the license and sale. diff --git a/packages/create-keyforge/src/generate.mjs b/packages/create-keyforge/src/generate.mjs index 91b88a3..af26776 100644 --- a/packages/create-keyforge/src/generate.mjs +++ b/packages/create-keyforge/src/generate.mjs @@ -215,11 +215,10 @@ function envBlock(lines) { } /** - * `.env.local` — the file every npm script in the template loads. Secrets are + * `.env` — the file every npm script in the template loads. Secrets are * generated, not placeheld: a deployer who never edits this file still gets a * unique admin token and session secret rather than a shared example value. - * The signing key is the one thing left blank, because `npm run license - * keygen` has to write it. + * The signing key is left blank for `secrets:init` to generate. */ export function buildEnvLocal(answers) { const lines = [ @@ -227,8 +226,11 @@ export function buildEnvLocal(answers) { "# Secrets below were generated for this project. Never commit this file;", "# set the same variables in your host's dashboard when you deploy.", "", - `# The trust root. Generate the pair with: ${commands(answers.packageManager).run("license")} keygen`, - "LICENSE_SIGNING_KEY_FILE=./secrets/license-signing.key", + `# Initialize the trust root with: ${commands(answers.packageManager).run("secrets:init")} --yes`, + "# Ed25519 private key; secrets:init fills this without printing it.", + "LICENSE_SIGNING_KEY=", + "", + "# Encryption keyrings are created by secrets:init and kept under secrets/.", "LICENSE_DATA_KEYRING_FILE=./secrets/data.keyring.json", "LICENSE_BACKUP_KEYRING_FILE=./secrets/backup.keyring.json", "", @@ -242,6 +244,13 @@ export function buildEnvLocal(answers) { 'LICENSE_SERVER_URL="http://localhost:4100/"', "", ]; + if (answers.platform === "docker" && answers.database === "postgres") { + lines.push( + "# Host port for the bundled Postgres. Use 5433 if 5432 is already taken.", + "POSTGRES_PORT=5432", + "", + ); + } if (answers.database === "postgres") { lines.push( "# Ledger and operator accounts both live in Postgres. Any provider works;", @@ -268,12 +277,12 @@ export function buildEnvLocal(answers) { return envBlock(lines); } -/** The committable twin of `.env.local`: same variables, no values. */ +/** The committable twin of `.env`: same variables, no values. */ export function buildEnvExample(answers) { const lines = [ - `# ${answers.productName} license service — copy to .env.local and fill in.`, - `# Generate the signing key pair with: ${commands(answers.packageManager).run("license")} keygen`, - "LICENSE_SIGNING_KEY_FILE=./secrets/license-signing.key", + `# ${answers.productName} license service — copy to .env and fill in.`, + `# Generate the signing key with: ${commands(answers.packageManager).run("secrets:init")} --yes`, + "LICENSE_SIGNING_KEY=", "LICENSE_DATA_KEYRING_FILE=./secrets/data.keyring.json", "LICENSE_BACKUP_KEYRING_FILE=./secrets/backup.keyring.json", '# LICENSE_SIGNING_KEY="-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n"', @@ -601,13 +610,23 @@ export function nextSteps(answers) { const steps = [ `cd ${answers.relativeDirectory}`, ...(answers.installed ? [] : [run.install]), - `${run.run("license")} keygen`.padEnd(29) + "# writes the signing key pair into secrets/", + `${run.run("secrets:init")} --yes`.padEnd(29) + + "# writes the signing key and missing secrets into .env", + `${run.run("env:check")}`.padEnd(29) + "# validate .env with Varlock", ]; if (answers.database === "postgres") { - steps.push("# point DATABASE_URL at your Postgres instance in .env.local"); + steps.push("# point DATABASE_URL at your Postgres instance in .env"); + } + steps.push(run.run("build:all").padEnd(29) + "# server -> dist/, console -> dist/public"); + if (answers.platform === "docker" && answers.database === "postgres") { + steps.push(...platformSteps(answers).slice(0, 2)); + steps.push("docker compose up -d --build # start Postgres and the server"); + steps.push( + run.runWithFlags("admin:create", '--email you@example.com --password ""'), + ); + return steps; } steps.push( - run.run("build:all").padEnd(29) + "# server -> dist/, console -> dist/public", run.runWithFlags("admin:create", '--email you@example.com --password ""'), run.run("start").padEnd(29) + "# console on http://localhost:4100/", ); @@ -632,6 +651,16 @@ function describeAuthScope(scope) { */ export function buildReadme(answers) { const run = commands(answers.packageManager); + const dockerPostgres = answers.platform === "docker" && answers.database === "postgres"; + const startSteps = dockerPostgres + ? [ + "docker compose up -d --build # start Postgres and the server", + run.runWithFlags("admin:create", '--email you@example.com --password ""'), + ] + : [ + run.runWithFlags("admin:create", '--email you@example.com --password ""'), + run.run("start").padEnd(29) + "# console on http://localhost:4100/", + ]; const useCase = USE_CASES.find((entry) => entry.id === answers.useCase); const plans = Object.keys(buildConfig(answers).licenses.planPresets).join(", "); const database = @@ -659,21 +688,24 @@ documentation is kept in [docs/keyforge.md](docs/keyforge.md). Deployment policy lives in \`keyforge.config.json\`. Its catalog seeds the first product; later plan and entitlement changes are saved in the database through -the console. Secrets live in \`.env.local\`, never in the policy file. +the console. Secrets live in \`.env\`, never in the policy file. ## Run it \`\`\`bash ${run.install} -${`${run.run("license")} keygen`.padEnd(29)}# one-time: the Ed25519 signing key pair +${`${run.run("secrets:init")} --yes`.padEnd(29)}# one-time: signing key and missing secrets in .env +${run.run("env:check").padEnd(29)}# validate the local environment with Varlock ${run.run("build:all").padEnd(29)}# server -> dist/, console -> dist/public -${run.runWithFlags("admin:create", '--email you@example.com --password ""')} -${run.run("start").padEnd(29)}# console on http://localhost:4100/ +${startSteps.join("\n")} \`\`\` -The private signing key is the trust root: it exists only on this server, and +${dockerPostgres ? "For host commands, `DATABASE_URL` uses `localhost:5432` (or `POSTGRES_PORT` if changed). Compose uses `postgres:5432` inside Docker. If you change `POSTGRES_PASSWORD`, set `COMPOSE_DATABASE_URL` to match.\n\n" : ""}The private signing key is the trust root: it exists only on this server, and your app ships the public half to verify tokens offline. Never commit it. +For optional environment validation and secret management, see +[Varlock's introduction](https://varlock.dev/getting-started/introduction/). + ## Plans ${plans diff --git a/packages/create-keyforge/src/index.mjs b/packages/create-keyforge/src/index.mjs index 7fb2b08..8bedcaa 100644 --- a/packages/create-keyforge/src/index.mjs +++ b/packages/create-keyforge/src/index.mjs @@ -249,7 +249,7 @@ async function writeProject(directory, answers) { await writeFile(at("keyforge.config.json"), `${JSON.stringify(buildConfig(answers), null, 2)}\n`); await writeFile( - at(".env.local"), + at(".env"), buildEnvLocal({ ...answers, adminToken: secret(), authSecret: secret() }), ); await writeFile(at(".env.example"), buildEnvExample(answers)); @@ -401,6 +401,6 @@ function report(io, answers, context) { for (const note of notes) io.write(` ! ${note}\n`); } io.write( - `\n${dim(".env.local holds generated secrets — it is gitignored; set the same variables on your host.")}\n\n`, + `\n${dim(".env holds generated secrets — it is gitignored; set the same variables on your host.")}\n\n`, ); } diff --git a/packages/create-keyforge/src/platforms.mjs b/packages/create-keyforge/src/platforms.mjs index ba48ce0..44a0b35 100644 --- a/packages/create-keyforge/src/platforms.mjs +++ b/packages/create-keyforge/src/platforms.mjs @@ -329,7 +329,8 @@ const DOCKER = { files: (answers) => ({ "docker-compose.yml": composeYaml(answers) }), keep: [], steps: (answers) => [ - "# put the same variables from .env.local into .env (Compose reads it)", + "# Compose reads the generated .env; initialize its signing key first", + "# if host Postgres port 5432 is occupied, set POSTGRES_PORT=5433 in .env", answers.database === "postgres" ? "docker compose up --build # ledger and postgres together" : "docker compose up --build # SQLite in the named volume", @@ -355,6 +356,7 @@ function composeYaml(answers) { " volumes:", ` - ${name}-data:/data`, " - ./keyforge.config.json:/app/keyforge.config.json:ro", + " - ./secrets:/app/secrets:ro", " environment:", " # Required: the PEM private key (or mount a file and set _FILE).", " LICENSE_SIGNING_KEY: ${LICENSE_SIGNING_KEY:?set it in .env}", @@ -364,18 +366,22 @@ function composeYaml(answers) { " LICENSE_SEED_ADMIN_EMAIL: ${LICENSE_SEED_ADMIN_EMAIL:-}", " LICENSE_SEED_ADMIN_PASSWORD: ${LICENSE_SEED_ADMIN_PASSWORD:-}", " LICENSE_SERVER_URL: ${LICENSE_SERVER_URL:-}", + " LICENSE_DATA_KEYRING_FILE: ${LICENSE_DATA_KEYRING_FILE:-./secrets/data.keyring.json}", + " LICENSE_BACKUP_KEYRING_FILE: ${LICENSE_BACKUP_KEYRING_FILE:-./secrets/backup.keyring.json}", ]; if (postgres) { lines.push( " # Compose does not nest substitutions: change POSTGRES_PASSWORD and you", " # must set DATABASE_URL to match.", - " DATABASE_URL: ${DATABASE_URL:-postgres://keyforge:keyforge@postgres:5432/keyforge}", + " DATABASE_URL: ${COMPOSE_DATABASE_URL:-postgres://keyforge:keyforge@postgres:5432/keyforge?sslmode=disable}", " depends_on:", " postgres:", " condition: service_healthy", "", " postgres:", " image: postgres:18.6", + " ports:", + ' - "${POSTGRES_PORT:-5432}:5432"', " environment:", " POSTGRES_USER: keyforge", " POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-keyforge}", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c21986..24d8c42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,9 +188,6 @@ importers: '@fastify/static': specifier: ^10.1.6 version: 10.1.6 - '@t3-oss/env-core': - specifier: ^0.13.11 - version: 0.13.11(typescript@7.0.2)(zod@4.6.5) '@vercel/blob': specifier: ^2.8.2 version: 2.8.2 @@ -215,6 +212,9 @@ importers: uploadthing: specifier: ^7.7.4 version: 7.7.4(fastify@5.12.5)(tailwindcss@4.3.3) + varlock: + specifier: ^1.23.0 + version: 1.23.0 zod: specifier: ^4.6.5 version: 4.6.5 @@ -2127,23 +2127,6 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@t3-oss/env-core@0.13.11': - resolution: {integrity: sha512-sM7GYY+KL7H/Hl0BE0inWfk3nRHZOLhmVn7sHGxaZt9FAR6KqREXAE+6TqKfiavfXmpRxO/OZ2QgKRd+oiBYRQ==} - peerDependencies: - arktype: ^2.1.0 - typescript: '>=5.0.0' - valibot: ^1.0.0-beta.7 || ^1.0.0 - zod: ^3.24.0 || ^4.0.0 - peerDependenciesMeta: - arktype: - optional: true - typescript: - optional: true - valibot: - optional: true - zod: - optional: true - '@tailwindcss/node@4.3.3': resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} @@ -2449,6 +2432,24 @@ packages: '@uploadthing/shared@7.1.10': resolution: {integrity: sha512-R/XSA3SfCVnLIzFpXyGaKPfbwlYlWYSTuGjTFHuJhdAomuBuhopAHLh2Ois5fJibAHzi02uP1QCKbgTAdmArqg==} + '@varlock/native-helper-darwin@1.23.0': + resolution: {integrity: sha512-BdDfoQvsfDOiMMneU8NjIsPA0d6vfkSXl9F1qHtiL0Cht1Qn5u4EWKhK5atDF4iYDwI/IXjaWVZ9xIKqQb1XOA==} + os: [darwin] + + '@varlock/native-helper-linux-arm64@1.23.0': + resolution: {integrity: sha512-+9bDk8ql64DLA8mPejpbDP7kezk43E+RZK6/tsfQenj/LeW0pziP7XwsJZ8WhLVkrRDyZ6DJfKTjxB06vxu0jg==} + cpu: [arm64] + os: [linux] + + '@varlock/native-helper-linux-x64@1.23.0': + resolution: {integrity: sha512-gIWpGcXfHgo5Pci2aMRUxPN73QvkR0iwEOpBkdA+WINAl428cszkj5lt1fetjL889V29U4+S41XvC4Vk7u2ujQ==} + cpu: [x64] + os: [linux] + + '@varlock/native-helper-win32-x64@1.23.0': + resolution: {integrity: sha512-X55fq6Q6Fj3DHwEz1R87WiMEhG6vOCqwZp5D/gy/vqPmmC0ujOWxUItkK32jhn4BspXu5vHtv3j25GfL4/toGQ==} + os: [win32, linux] + '@vercel/analytics@2.0.1': resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==} peerDependencies: @@ -4181,6 +4182,11 @@ packages: '@types/react': optional: true + varlock@1.23.0: + resolution: {integrity: sha512-eHJ6PxWJJqBq/+Ki2Uc9HNClpRS4JDLZ9YCL6vJ1f9eGSJxrUOPeDdVpmYcrmjms+RRtEVheiDvYHJJ1WrE3kg==} + engines: {bun: '>=1.3.3', node: '>=22.3.0'} + hasBin: true + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -5972,11 +5978,6 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@t3-oss/env-core@0.13.11(typescript@7.0.2)(zod@4.6.5)': - optionalDependencies: - typescript: 7.0.2 - zod: 4.6.5 - '@tailwindcss/node@4.3.3': dependencies: '@jridgewell/remapping': 2.3.5 @@ -6204,6 +6205,18 @@ snapshots: effect: 3.22.2 sqids: 0.3.0 + '@varlock/native-helper-darwin@1.23.0': + optional: true + + '@varlock/native-helper-linux-arm64@1.23.0': + optional: true + + '@varlock/native-helper-linux-x64@1.23.0': + optional: true + + '@varlock/native-helper-win32-x64@1.23.0': + optional: true + '@vercel/analytics@2.0.1(react@19.3.0)': optionalDependencies: react: 19.3.0 @@ -8026,6 +8039,13 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 + varlock@1.23.0: + optionalDependencies: + '@varlock/native-helper-darwin': 1.23.0 + '@varlock/native-helper-linux-arm64': 1.23.0 + '@varlock/native-helper-linux-x64': 1.23.0 + '@varlock/native-helper-win32-x64': 1.23.0 + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 diff --git a/scripts/init-secrets.mjs b/scripts/init-secrets.mjs index 286bd7e..cc022e0 100644 --- a/scripts/init-secrets.mjs +++ b/scripts/init-secrets.mjs @@ -1,5 +1,5 @@ import { generateKeyPairSync, randomBytes } from "node:crypto"; -import { mkdir, writeFile, access } from "node:fs/promises"; +import { mkdir, writeFile, readFile } from "node:fs/promises"; import { dirname } from "node:path"; import { @@ -11,7 +11,7 @@ import { if (process.argv.includes("--help") || process.argv.includes("-h")) { console.log( - "Usage: vp run secrets:init [--env-file PATH] [--secrets-dir PATH] [--yes]\nFill missing server secrets without rotating existing keys. Defaults to .env.local; named environments own their keys/ directory.", + "Usage: vp run secrets:init [--env-file PATH] [--secrets-dir PATH] [--yes]\nFill missing server secrets without rotating existing keys. Defaults to .env; named environments own their keys/ directory.", ); process.exit(0); } @@ -65,24 +65,21 @@ for (const name of ["data", "backup"]) { settings[variable + "_FILE"] = path; } if (!configured("LICENSE_SIGNING_KEY")) { - const path = configured("LICENSE_SIGNING_KEY_FILE") || keyPath("license-signing.key"); - await mkdir(dirname(path), { recursive: true, mode: 0o700 }); - try { - await access(path); - console.log("Existing signing key retained."); - } catch (error) { - if (error.code !== "ENOENT") throw error; + const path = configured("LICENSE_SIGNING_KEY_FILE"); + if (path) { + await readFile(path, "utf8"); + console.log("Existing signing key file retained."); + } else { const pair = generateKeyPairSync("ed25519"); - await writeFile(path, pair.privateKey.export({ type: "pkcs8", format: "pem" }), { - flag: "wx", - mode: 0o600, - }); - console.log("Created private signing key in " + path + "."); + settings.LICENSE_SIGNING_KEY = pair.privateKey + .export({ type: "pkcs8", format: "pem" }) + .trim() + .replaceAll("\n", "\\n"); + console.log("Created private signing key in " + options.file + "."); console.log( "Client PUBLIC verification key:\n" + pair.publicKey.export({ type: "spki", format: "pem" }), ); } - settings.LICENSE_SIGNING_KEY_FILE = path; } for (const name of ["LICENSE_ADMIN_TOKEN", "LICENSE_AUTH_SECRET", "CRON_SECRET"]) if (!configured(name)) settings[name] = randomBytes(32).toString("hex"); diff --git a/scripts/local-env.mjs b/scripts/local-env.mjs index 78570f8..cfe28ac 100644 --- a/scripts/local-env.mjs +++ b/scripts/local-env.mjs @@ -14,7 +14,7 @@ export function localEnvironmentOptions(args) { }; const selected = option("env-file"); return { - file: selected || ".env.local", + file: selected || ".env", explicit: Boolean(selected), // A named environment owns its keys; it must not silently reuse local keys. secretsDir: option("secrets-dir") || (selected ? join(dirname(selected), "keys") : "./secrets"), @@ -47,7 +47,7 @@ export async function writeEnvironment(settings, file) { ); } -export async function readLocalEnv(file = ".env.local") { +export async function readLocalEnv(file = ".env") { try { const source = await readFile(file, "utf8"); return { exists: true, source, values: parseEnv(source) }; @@ -70,7 +70,7 @@ export async function confirmLocalUpdate(message, yes = false) { } } /** Preserve comments and unrelated variables; never print secret values. */ -export async function saveLocalEnv(settings, file = ".env.local") { +export async function saveLocalEnv(settings, file = ".env") { const { source } = await readLocalEnv(file); const remaining = new Map(Object.entries(settings)); const lines = source.split(/\r?\n/).flatMap((line) => { diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index 6ab9bfd..ad29a20 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -195,7 +195,7 @@ try { } else { assert.ok(pkg.dependencies["@zdevzone/keyforge-client"]); await run("vp", ["run", "typecheck"], project, env); - await cp(join(project, ".env.example"), join(project, ".env.local")); + await cp(join(project, ".env.example"), join(project, ".env")); const demo = await run("vp", ["run", "demo"], project, env); assert.match(demo.stdout, /Authorization: unlicensed/); await writeFile( diff --git a/scripts/runtime-package.mjs b/scripts/runtime-package.mjs index b366afe..2eb2f70 100644 --- a/scripts/runtime-package.mjs +++ b/scripts/runtime-package.mjs @@ -37,7 +37,7 @@ await writeFile( engines: source.engines, dependencies, overrides: source.overrides, - scripts: { start: "node --env-file-if-exists=.env.local dist/src/index.js" }, + scripts: { start: "node --env-file-if-exists=.env dist/src/index.js" }, }, null, 2, diff --git a/scripts/storage-init.mjs b/scripts/storage-init.mjs index 784de6d..7d0b3e9 100644 --- a/scripts/storage-init.mjs +++ b/scripts/storage-init.mjs @@ -21,7 +21,7 @@ if (!["all", "s3", "r2"].includes(provider)) throw Error("Use --provider all, s3 const name = value("name") || "keyforge-storage-" + randomBytes(6).toString("hex"); if (!/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/.test(name)) throw Error("Use a lowercase bucket name with 3–63 letters, digits or hyphens."); -const file = value("env-file") || ".env.local"; +const file = value("env-file") || ".env"; let source = await readFile(file, "utf8"); const env = { ...parseEnv(source), ...process.env }, options = storageOptionsFromEnv(env); diff --git a/src/env.ts b/src/env.ts index 71bb9b7..1669110 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,4 +1,3 @@ -import { createEnv } from "@t3-oss/env-core"; import { z } from "zod"; /** @@ -18,7 +17,7 @@ import { z } from "zod"; * * ## Almost everything here is optional, on purpose * - * This is not the usual T3 Env shape, where absent variables are errors. Most + * Most * of Keyforge's configuration is *absence-driven*, and those defaults are * promises the README makes: * @@ -149,28 +148,29 @@ export const envSchema = { * presence check and fails everything after it. */ export function parseEnv(runtimeEnv: Record = process.env) { - return createEnv({ - server: envSchema, - runtimeEnv: { - ...runtimeEnv, - LICENSE_SERVER_URL: - runtimeEnv.LICENSE_SERVER_URL || - runtimeEnv.RENDER_EXTERNAL_URL || - (runtimeEnv.VERCEL_URL ? `https://${runtimeEnv.VERCEL_URL}` : undefined), - }, - emptyStringAsUndefined: true, - onValidationError: (issues) => { - const lines = issues.map((issue) => { - const name = issue.path?.join(".") ?? "(unknown)"; - return ` ${name}: ${issue.message}`; - }); - throw new Error( - `Environment is not usable:\n${lines.join("\n")}\n\n` + - "See docs/deploy.md for what each variable does. Most are optional — " + - "an unset variable is fine, a malformed one is not.", - ); - }, - }); + const parsed = z.object(envSchema).safeParse( + Object.fromEntries( + Object.entries({ + ...runtimeEnv, + LICENSE_SERVER_URL: + runtimeEnv.LICENSE_SERVER_URL || + runtimeEnv.RENDER_EXTERNAL_URL || + (runtimeEnv.VERCEL_URL ? `https://${runtimeEnv.VERCEL_URL}` : undefined), + }).map(([name, value]) => [name, value === "" ? undefined : value]), + ), + ); + if (!parsed.success) { + const lines = parsed.error.issues.map((issue) => { + const name = issue.path?.join(".") ?? "(unknown)"; + return ` ${name}: ${issue.message}`; + }); + throw new Error( + `Environment is not usable:\n${lines.join("\n")}\n\n` + + "See docs/deploy.md for what each variable does. Most are optional — " + + "an unset variable is fine, a malformed one is not.", + ); + } + return parsed.data; } export type Env = ReturnType; diff --git a/test/create-keyforge.test.ts b/test/create-keyforge.test.ts index 62e8bde..017656b 100644 --- a/test/create-keyforge.test.ts +++ b/test/create-keyforge.test.ts @@ -167,8 +167,8 @@ test("generated env carries distinct real secrets, not placeholders", () => { assert.match(env, /^LICENSE_ADMIN_TOKEN="a{43}"$/m); assert.match(env, /^LICENSE_AUTH_SECRET="b{43}"$/m); // The signing key is never invented here — keygen has to write it. - assert.doesNotMatch(env, /^LICENSE_SIGNING_KEY=/m); - assert.match(env, /^LICENSE_SIGNING_KEY_FILE=/m); + assert.match(env, /^LICENSE_SIGNING_KEY=$/m); + assert.doesNotMatch(env, /^LICENSE_SIGNING_KEY_FILE=/m); assert.match( buildEnvLocal({ productName: "Acme", @@ -257,7 +257,7 @@ test("scaffolds a working project from a local template", async (t) => { assert.deepEqual(config.auth.admin, ["operators", "sessions", "bans"]); assert.equal(config.licenses.defaultPlan, "monthly"); - const env = await readFile(join(directory, ".env.local"), "utf8"); + const env = await readFile(join(directory, ".env"), "utf8"); const adminToken = env.match(/^LICENSE_ADMIN_TOKEN="(.+)"$/m)?.[1] ?? ""; const authSecret = env.match(/^LICENSE_AUTH_SECRET="(.+)"$/m)?.[1] ?? ""; assert.ok(adminToken.length >= 32, "admin token should be long"); @@ -333,7 +333,7 @@ test("scaffolds a working project from a local template", async (t) => { run("git", ["check-ignore", "--quiet", ".env.example"], { cwd: directory }), ".env.example is meant to be committed", ); - await run("git", ["check-ignore", "--quiet", ".env.local"], { cwd: directory }); + await run("git", ["check-ignore", "--quiet", ".env"], { cwd: directory }); }); test("refuses to overwrite a directory that already has files", async (t) => { @@ -739,11 +739,17 @@ test("the generated compose file is one Compose accepts", async (t) => { join(directory, "docker-compose.yml"), platformFiles(answers)["docker-compose.yml"], ); - await run("docker", ["compose", "config"], { + const { stdout } = await run("docker", ["compose", "config", "--format", "json"], { cwd: directory, // LICENSE_SIGNING_KEY is declared required (:?); supply anything. - env: { ...process.env, LICENSE_SIGNING_KEY: "test-key" }, + env: { ...process.env, LICENSE_SIGNING_KEY: "test-key", POSTGRES_PORT: "5433" }, }); + const config = JSON.parse(stdout); + assert.equal(config.services["acme-licensing"].ports[0].published, "4100"); + if (database === "postgres") { + assert.equal(config.services.postgres.ports[0].published, "5433"); + assert.match(config.services["acme-licensing"].environment.DATABASE_URL, /sslmode=disable$/); + } } }); diff --git a/test/secret-setup.test.ts b/test/secret-setup.test.ts index 6b8427a..35921ca 100644 --- a/test/secret-setup.test.ts +++ b/test/secret-setup.test.ts @@ -21,7 +21,7 @@ const cleanEnv = Object.fromEntries( test("environment updates preserve Windows paths, comments and unrelated settings without duplicate keys", async (t) => { const cwd = await mkdtemp(join(tmpdir(), "keyforge-env-")); t.after(() => rm(cwd, { recursive: true, force: true })); - const file = join(cwd, ".env.local"); + const file = join(cwd, ".env"); await writeFile( file, "# Existing settings\nLICENSE_SIGNING_KEY_FILE=old\nLICENSE_SIGNING_KEY_FILE=duplicate\nUNRELATED=keep\nconstructor=keep-too\n", @@ -46,16 +46,16 @@ test("fresh secret setup writes a complete local environment and never rotates e stdio: ["pipe", "pipe", "pipe"], }); const output = run(); - const path = join(cwd, ".env.local"); + const path = join(cwd, ".env"); const source = await readFile(path, "utf8"), values = parseEnv(source); for (const key of ["LICENSE_AUTH_SECRET", "LICENSE_ADMIN_TOKEN", "CRON_SECRET"]) { assert.equal(values[key]!.length, 64); assert.equal(output.includes(values[key]!), false); } - const privateKey = await readFile(join(cwd, values.LICENSE_SIGNING_KEY_FILE!)); + const privateKey = values.LICENSE_SIGNING_KEY!.replaceAll("\\n", "\n"); assert.equal(createPublicKey(privateKey).asymmetricKeyType, "ed25519"); - assert.equal(values.LICENSE_SIGNING_KEY_FILE!, "./secrets/license-signing.key"); + assert.equal(values.LICENSE_SIGNING_KEY_FILE, undefined); const data = await readFile(join(cwd, values.LICENSE_DATA_KEYRING_FILE!), "utf8"); assert.notEqual(data, await readFile(join(cwd, values.LICENSE_BACKUP_KEYRING_FILE!), "utf8")); const refused = spawnSync(process.execPath, ["--", init], { @@ -68,16 +68,16 @@ test("fresh secret setup writes a complete local environment and never rotates e assert.equal(await readFile(path, "utf8"), source); run("--yes"); assert.equal(await readFile(path, "utf8"), source); - assert.deepEqual(await readFile(join(cwd, values.LICENSE_SIGNING_KEY_FILE!)), privateKey); + assert.equal( + parseEnv(await readFile(path, "utf8")).LICENSE_SIGNING_KEY, + values.LICENSE_SIGNING_KEY, + ); assert.equal(await readFile(join(cwd, values.LICENSE_DATA_KEYRING_FILE!), "utf8"), data); }); test("keygen appends its path, requires confirmation for existing setup and refuses accidental overwrite", async (t) => { const cwd = await mkdtemp(join(tmpdir(), "keyforge-keygen-")); t.after(() => rm(cwd, { recursive: true, force: true })); - await writeFile( - join(cwd, ".env.local"), - "# Keep this\nLICENSE_SERVER_URL=https://example.test\n", - ); + await writeFile(join(cwd, ".env"), "# Keep this\nLICENSE_SERVER_URL=https://example.test\n"); const run = (...args: string[]) => spawnSync(process.execPath, ["--", keygen, "keygen", ...args], { cwd, @@ -87,7 +87,7 @@ test("keygen appends its path, requires confirmation for existing setup and refu }); assert.notEqual(run().status, 0); assert.equal(run("--yes").status, 0); - const source = await readFile(join(cwd, ".env.local"), "utf8"); + const source = await readFile(join(cwd, ".env"), "utf8"); assert.ok(source.includes("# Keep this")); assert.equal(parseEnv(source).LICENSE_SIGNING_KEY_FILE, "./secrets/license-signing.key"); const key = await readFile(join(cwd, "secrets/license-signing.key")); @@ -98,7 +98,7 @@ test("keygen appends its path, requires confirmation for existing setup and refu test("named environments isolate keys and ignore ambient production credentials", async (t) => { const cwd = await mkdtemp(join(tmpdir(), "keyforge-named-secrets-")); t.after(() => rm(cwd, { recursive: true, force: true })); - await writeFile(join(cwd, ".env.local"), "# Local configuration must remain unchanged\n"); + await writeFile(join(cwd, ".env"), "# Local configuration must remain unchanged\n"); const env = { ...cleanEnv, LICENSE_SIGNING_KEY: "ambient-production-key", @@ -118,15 +118,10 @@ test("named environments isolate keys and ignore ambient production credentials" const values = parseEnv(source); assert.ok(!source.includes("ambient-")); assert.equal( - createPublicKey(await readFile(join(cwd, values.LICENSE_SIGNING_KEY_FILE!))) - .asymmetricKeyType, + createPublicKey(values.LICENSE_SIGNING_KEY!.replaceAll("\\n", "\n")).asymmetricKeyType, "ed25519", ); - assert.ok( - values - .LICENSE_SIGNING_KEY_FILE!.replaceAll("\\", "/") - .startsWith("secrets/" + target + "/keys/"), - ); + assert.equal(values.LICENSE_SIGNING_KEY_FILE, undefined); run(); assert.ok( source === (await readFile(join(cwd, file), "utf8")), @@ -136,7 +131,7 @@ test("named environments isolate keys and ignore ambient production credentials" } assert.ok(sources[0] !== sources[1], "Environments must not share generated secrets"); assert.equal( - await readFile(join(cwd, ".env.local"), "utf8"), + await readFile(join(cwd, ".env"), "utf8"), "# Local configuration must remain unchanged\n", ); const selected = join("secrets", "recovery", "server.env"); @@ -155,7 +150,7 @@ test("portable exports preserve signing/encryption material and omit platform cr const cwd = await mkdtemp(join(tmpdir(), "keyforge-export-secrets-")); t.after(() => rm(cwd, { recursive: true, force: true })); execFileSync(process.execPath, ["--", init], { cwd, env: cleanEnv, stdio: "pipe" }); - const sourceFile = join(cwd, ".env.local"); + const sourceFile = join(cwd, ".env"); await writeFile( sourceFile, (await readFile(sourceFile, "utf8")) + @@ -180,7 +175,11 @@ test("portable exports preserve signing/encryption material and omit platform cr ), ); for (const name of ["LICENSE_SIGNING_KEY", "LICENSE_DATA_KEYRING", "LICENSE_BACKUP_KEYRING"]) { - assert.ok(exported[name] === (await readFile(join(cwd, values[name + "_FILE"]!), "utf8"))); + const expected = + name === "LICENSE_SIGNING_KEY" + ? values[name] + : await readFile(join(cwd, values[name + "_FILE"]!), "utf8"); + assert.ok(exported[name] === expected); assert.ok( !first.stdout.includes(exported[name]!), "Secret values must never reach command output", @@ -199,7 +198,7 @@ test("portable exports preserve signing/encryption material and omit platform cr test("masked host exports cannot silently generate replacement production keys", async (t) => { const cwd = await mkdtemp(join(tmpdir(), "keyforge-masked-secrets-")); t.after(() => rm(cwd, { recursive: true, force: true })); - await writeFile(join(cwd, ".env.local"), "LICENSE_SIGNING_KEY='[SENSITIVE]'\n"); + await writeFile(join(cwd, ".env"), "LICENSE_SIGNING_KEY='[SENSITIVE]'\n"); for (const [script, args] of [ [init, ["--yes"]], [exportSecrets, ["--output", "portable.env"]], From 594e56a065d93eda41ca7fceed8ba334676bc095 Mon Sep 17 00:00:00 2001 From: Abraham Date: Sat, 24 Oct 2026 08:52:18 +0300 Subject: [PATCH 2/2] fix: keep client starter smoke env local --- scripts/package-smoke.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index ad29a20..6ab9bfd 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -195,7 +195,7 @@ try { } else { assert.ok(pkg.dependencies["@zdevzone/keyforge-client"]); await run("vp", ["run", "typecheck"], project, env); - await cp(join(project, ".env.example"), join(project, ".env")); + await cp(join(project, ".env.example"), join(project, ".env.local")); const demo = await run("vp", ["run", "demo"], project, env); assert.match(demo.stdout, /Authorization: unlicensed/); await writeFile(