diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index 10f86fb..cc89ef3 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -71,11 +71,13 @@ jobs: ["adapters/react"]="react-v" ["adapters/vue"]="vue-v" ["adapters/svelte"]="svelte-v" + ["plugins/vite"]="vite-v" + ["plugins/next"]="next-v" ) TAGGED=0 - for pkg_path in "sdk" "cli" "codemod" "adapters/react" "adapters/vue" "adapters/svelte"; do + for pkg_path in "sdk" "cli" "codemod" "adapters/react" "adapters/vue" "adapters/svelte" "plugins/vite" "plugins/next"; do manifest_ver=$(jq -r --arg p "$pkg_path" '.[$p]' .release-please-manifest.json) pkg_ver=$(jq -r '.version' "${pkg_path}/package.json") prefix="${TAG_PREFIX[$pkg_path]}" @@ -284,6 +286,24 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Publish vite plugin + working-directory: plugins/vite + run: | + V=$(node -p "require('./package.json').version") + PUBLISHED=$(npm view @rep-protocol/vite@${V} version 2>/dev/null || true) + if [ "$PUBLISHED" = "$V" ]; then echo "vite@${V} already published, skipping."; else pnpm publish --provenance --access public --no-git-checks; fi + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Publish next plugin + working-directory: plugins/next + run: | + V=$(node -p "require('./package.json').version") + PUBLISHED=$(npm view @rep-protocol/next@${V} version 2>/dev/null || true) + if [ "$PUBLISHED" = "$V" ]; then echo "next@${V} already published, skipping."; else pnpm publish --provenance --access public --no-git-checks; fi + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + release-gateway: name: GoReleaser runs-on: ubuntu-latest diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index d0b360d..9f17ecd 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -8,6 +8,7 @@ on: - 'cli/**' - 'codemod/**' - 'adapters/**' + - 'plugins/**' - 'pnpm-workspace.yaml' - '.github/workflows/sdk.yml' pull_request: @@ -31,6 +32,7 @@ jobs: - 'cli/**' - 'codemod/**' - 'adapters/**' + - 'plugins/**' - 'pnpm-workspace.yaml' - '.github/workflows/sdk.yml' diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 67ef134..c3931b9 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -5,5 +5,7 @@ "adapters/react": "0.1.11", "adapters/vue": "0.1.11", "adapters/svelte": "0.1.11", + "plugins/vite": "0.1.11", + "plugins/next": "0.1.11", "gateway": "0.1.5" } diff --git a/CLAUDE.md b/CLAUDE.md index 8bbaf12..d2c7e23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ rep/ │ └── release-sdk.yml # Release workflow (npm + GoReleaser + Docker) │ ├── package.json # Monorepo root (pnpm 9.0.0, private) -├── pnpm-workspace.yaml # Workspace: sdk, cli, adapters/*, codemod, examples/* +├── pnpm-workspace.yaml # Workspace: sdk, cli, adapters/*, plugins/*, codemod, docs ├── pnpm-lock.yaml ├── release-please-config.json # Release-please config for all packages ├── .release-please-manifest.json # Per-package version tracker @@ -124,6 +124,30 @@ rep/ │ ├── vue/ # @rep-protocol/vue — useRep() composable │ └── svelte/ # @rep-protocol/svelte — repStore() │ +├── plugins/ +│ ├── vite/ # @rep-protocol/vite — Vite dev server plugin +│ │ ├── package.json # v0.1.10 +│ │ ├── src/ +│ │ │ ├── index.ts # repPlugin() — Vite plugin entry +│ │ │ ├── payload.ts # Payload builder (mirrors Go payload.go) +│ │ │ ├── crypto.ts # AES-256-GCM, HMAC-SHA256, SRI, HKDF (Node crypto) +│ │ │ ├── env.ts # Read env file + classify variables by prefix +│ │ │ ├── guardrails.ts # Shannon entropy + known format checks (mirrors cli/) +│ │ │ └── __tests__/ # 49 tests +│ │ └── vitest.config.ts +│ └── next/ # @rep-protocol/next — Next.js dev integration +│ ├── package.json # v0.1.10; exports "." (RepScript) + "./session-key" (route handler) +│ ├── src/ +│ │ ├── index.ts # RepScript RSC — renders - ``` - ```bash # Terminal 1: Vite dev server diff --git a/docs/src/content/docs/guides/development.mdx b/docs/src/content/docs/guides/development.mdx index 1409203..9789612 100644 --- a/docs/src/content/docs/guides/development.mdx +++ b/docs/src/content/docs/guides/development.mdx @@ -7,7 +7,72 @@ import { Tabs, TabItem, Aside } from '@astrojs/starlight/components'; During local development you typically don't have the REP gateway running. The SDK handles this gracefully — `rep.get()` returns `undefined` when no payload is present. -## Option A: Default values (simplest) +## Option A: Build-tool plugin (recommended) + +The easiest way to get full REP support in development. The plugin injects the same `\n'); + + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(testDir); + + try { + const { RepScript } = await import('../index.js'); + const result = RepScript({ env: '.env.local' }); + const json = result!.props.dangerouslySetInnerHTML.__html; + + // Must not contain raw < or > — all escaped to \u003c / \u003e + expect(json).not.toContain('<'); + expect(json).not.toContain('>'); + expect(json).toContain('\\u003c'); + expect(json).toContain('\\u003e'); + } finally { + process.env.NODE_ENV = origNodeEnv; + cwdSpy.mockRestore(); + } + }); + + it('strict mode throws on guardrail warnings', async () => { + const origNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + const envPath = join(testDir, '.env.local'); + writeFileSync(envPath, 'REP_PUBLIC_API_KEY=sk_live_abcdef1234567890abcdef\n'); + + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(testDir); + + try { + const { RepScript } = await import('../index.js'); + expect(() => RepScript({ env: '.env.local', strict: true })).toThrow(/guardrail/i); + } finally { + process.env.NODE_ENV = origNodeEnv; + cwdSpy.mockRestore(); + } + }); + + it('does not expose REP_SERVER_ vars in output', async () => { + const origNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + const envPath = join(testDir, '.env.local'); + writeFileSync(envPath, 'REP_PUBLIC_API=url\nREP_SERVER_DB_PASSWORD=supersecret\n'); + + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(testDir); + + try { + const { RepScript } = await import('../index.js'); + const result = RepScript({ env: '.env.local' }); + const json = result!.props.dangerouslySetInnerHTML.__html; + + expect(json).not.toContain('supersecret'); + expect(json).not.toContain('DB_PASSWORD'); + } finally { + process.env.NODE_ENV = origNodeEnv; + cwdSpy.mockRestore(); + } + }); + + it('does not expose non-REP env vars in output', async () => { + const origNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + process.env.SECRET_THING = 'should-not-appear'; + + const envPath = join(testDir, '.env.local'); + writeFileSync(envPath, 'REP_PUBLIC_API=url\nDATABASE_URL=postgres://secret\n'); + + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(testDir); + + try { + const { RepScript } = await import('../index.js'); + const result = RepScript({ env: '.env.local' }); + const json = result!.props.dangerouslySetInnerHTML.__html; + + expect(json).not.toContain('should-not-appear'); + expect(json).not.toContain('SECRET_THING'); + expect(json).not.toContain('postgres://secret'); + expect(json).not.toContain('DATABASE_URL'); + } finally { + process.env.NODE_ENV = origNodeEnv; + delete process.env.SECRET_THING; + cwdSpy.mockRestore(); + } + }); +}); diff --git a/plugins/next/src/__tests__/session-key.test.ts b/plugins/next/src/__tests__/session-key.test.ts new file mode 100644 index 0000000..a4805e4 --- /dev/null +++ b/plugins/next/src/__tests__/session-key.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +beforeEach(() => { + vi.resetModules(); +}); + +describe('GET /rep/session-key', () => { + it('returns 404 in production', async () => { + const origNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + try { + const { GET } = await import('../session-key.js'); + const response = GET(); + + expect(response.status).toBe(404); + const body = await response.json(); + expect(body.error).toContain('disabled in production'); + } finally { + process.env.NODE_ENV = origNodeEnv; + } + }); + + it('returns encryption key in development', async () => { + const origNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + try { + const { GET } = await import('../session-key.js'); + const response = GET(); + + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.key).toBeDefined(); + expect(typeof body.key).toBe('string'); + expect(body.expires_at).toBeDefined(); + + // Key should be valid base64 of 32 bytes + const keyBuf = Buffer.from(body.key, 'base64'); + expect(keyBuf).toHaveLength(32); + + // Verify headers + expect(response.headers.get('Cache-Control')).toBe('no-store'); + expect(response.headers.get('Content-Type')).toBe('application/json'); + } finally { + process.env.NODE_ENV = origNodeEnv; + } + }); + + it('returns the same key across multiple calls (singleton)', async () => { + const origNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + try { + const { GET } = await import('../session-key.js'); + const body1 = await GET().json(); + const body2 = await GET().json(); + + expect(body1.key).toBe(body2.key); + } finally { + process.env.NODE_ENV = origNodeEnv; + } + }); + + it('returns key that can decrypt RepScript sensitive blob', async () => { + const origNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + // Use dynamic imports so they share the same singleton keys + const { writeFileSync, mkdirSync, rmSync } = await import('node:fs'); + const { join } = await import('node:path'); + const { tmpdir } = await import('node:os'); + const { createDecipheriv } = await import('node:crypto'); + + const testDir = join(tmpdir(), 'rep-next-sk-decrypt-' + process.pid); + mkdirSync(testDir, { recursive: true }); + + const envPath = join(testDir, '.env.local'); + writeFileSync(envPath, 'REP_PUBLIC_API=url\nREP_SENSITIVE_SECRET=my-secret-value\n'); + + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(testDir); + + try { + // Import both from same module context so they share keys + const { RepScript } = await import('../index.js'); + const { GET } = await import('../session-key.js'); + + // Get the payload from RepScript + const element = RepScript({ env: '.env.local' }); + const json = element!.props.dangerouslySetInnerHTML.__html; + const parsed = JSON.parse(json); + + // Get the key from session-key endpoint + const response = GET(); + const { key } = await response.json(); + const encKey = Buffer.from(key, 'base64'); + + // Decrypt the sensitive blob + const data = Buffer.from(parsed.sensitive, 'base64'); + const nonce = data.subarray(0, 12); + const authTag = data.subarray(data.length - 16); + const ciphertext = data.subarray(12, data.length - 16); + + const decipher = createDecipheriv('aes-256-gcm', encKey, nonce); + decipher.setAuthTag(authTag); + decipher.setAAD(Buffer.from(parsed._meta.integrity)); + + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + const decrypted = JSON.parse(plaintext.toString()); + + expect(decrypted.SECRET).toBe('my-secret-value'); + } finally { + process.env.NODE_ENV = origNodeEnv; + cwdSpy.mockRestore(); + rmSync(testDir, { recursive: true, force: true }); + } + }); +}); diff --git a/plugins/next/src/crypto.ts b/plugins/next/src/crypto.ts new file mode 100644 index 0000000..be424e7 --- /dev/null +++ b/plugins/next/src/crypto.ts @@ -0,0 +1,125 @@ +import { createCipheriv, createHmac, createHash, randomBytes } from 'node:crypto'; + +export interface Keys { + encryptionKey: Buffer; + hmacSecret: Buffer; +} + +/** + * HKDF-SHA256 key derivation (RFC 5869), single-round. + * Matches gateway/internal/crypto/crypto.go:DeriveKey exactly. + * + * Extract: PRK = HMAC-SHA256(salt, ikm) + * Expand: T(1) = HMAC-SHA256(PRK, info || 0x01) + */ +export function deriveKey(ikm: Buffer, salt: Buffer, info: string, length: number): Buffer { + if (length > 32) { + throw new Error('rep: deriveKey length exceeds one HKDF-SHA256 round (max 32)'); + } + + // Extract + const prk = createHmac('sha256', salt).update(ikm).digest(); + + // Expand + const okm = createHmac('sha256', prk) + .update(info) + .update(Buffer.from([0x01])) + .digest(); + + return okm.subarray(0, length); +} + +/** + * Generate ephemeral keys matching Go's GenerateKeys(). + * Master key is HKDF-derived, then discarded. + */ +export function generateKeys(): Keys { + const masterKey = randomBytes(32); + const startupSalt = randomBytes(32); + const encryptionKey = deriveKey(masterKey, startupSalt, 'rep-blob-encryption-v1', 32); + const hmacSecret = randomBytes(32); + + return { encryptionKey, hmacSecret }; +} + +/** + * Produce deterministic JSON with sorted keys, using Go-compatible HTML escaping. + * Go's json.Marshal escapes <, >, & as \u003c, \u003e, \u0026. + */ +export function canonicalize(m: Record): string { + const sorted = Object.keys(m).sort(); + const obj: Record = {}; + for (const k of sorted) { + obj[k] = m[k]; + } + return goEscapeJSON(JSON.stringify(obj)); +} + +/** + * Apply Go's json.Marshal HTML escaping to a JSON string. + * Replaces <, >, & with their unicode escape sequences. + */ +export function goEscapeJSON(json: string): string { + return json + .replace(//g, '\\u003e') + .replace(/&/g, '\\u0026'); +} + +/** + * HMAC-SHA256 integrity token. + * message = canonicalize(public) + "|" + sensitiveBlob + * Returns "hmac-sha256:" + */ +export function computeIntegrity( + publicMap: Record, + sensitiveBlob: string, + hmacKey: Buffer, +): string { + const canonical = canonicalize(publicMap); + const message = canonical + '|' + sensitiveBlob; + const sig = createHmac('sha256', hmacKey).update(message).digest('base64'); + return 'hmac-sha256:' + sig; +} + +/** + * AES-256-GCM encryption matching Go's EncryptSensitive. + * Output: base64([nonce 12B][ciphertext][authTag 16B]) + * AAD = integrityToken string. + * + * The sensitive map is serialized with sorted keys and Go HTML escaping + * to match Go's json.Marshal output. + */ +export function encryptSensitive( + sensitiveMap: Record, + key: Buffer, + integrityToken: string, +): string { + if (Object.keys(sensitiveMap).length === 0) { + return ''; + } + + // Serialize with sorted keys + Go escaping to match Go's json.Marshal + const plaintext = Buffer.from(goEscapeJSON(JSON.stringify( + Object.fromEntries(Object.keys(sensitiveMap).sort().map(k => [k, sensitiveMap[k]])), + ))); + + const nonce = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key, nonce); + cipher.setAAD(Buffer.from(integrityToken)); + + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const authTag = cipher.getAuthTag(); + + // Format: [nonce][ciphertext][authTag] — matches Go's gcm.Seal(nonce, nonce, plaintext, aad) + const blob = Buffer.concat([nonce, encrypted, authTag]); + return blob.toString('base64'); +} + +/** + * SHA-256 SRI hash. Returns "sha256-". + */ +export function computeSRI(jsonBytes: Buffer): string { + const hash = createHash('sha256').update(jsonBytes).digest('base64'); + return 'sha256-' + hash; +} diff --git a/plugins/next/src/env.ts b/plugins/next/src/env.ts new file mode 100644 index 0000000..0e55ccb --- /dev/null +++ b/plugins/next/src/env.ts @@ -0,0 +1,87 @@ +import { readFileSync, existsSync } from 'node:fs'; +import { parse as parseDotenv } from 'dotenv'; + +export interface ClassifiedVars { + public: Record; + sensitive: Record; + server: Record; +} + +/** + * Read and parse a .env file. Returns empty object if file doesn't exist. + */ +export function readEnvFile(path: string): Record { + if (!existsSync(path)) { + return {}; + } + const content = readFileSync(path, 'utf-8'); + return parseDotenv(Buffer.from(content)); +} + +/** + * Classify REP_* variables by prefix into public/sensitive/server tiers. + * Strips the REP__ prefix from names. + * Rejects name collisions across tiers. + * + * Matches gateway/internal/config/classify.go:ReadAndClassify logic. + */ +export function classifyVariables(envValues: Record): ClassifiedVars { + const result: ClassifiedVars = { + public: {}, + sensitive: {}, + server: {}, + }; + + // Track stripped names for collision detection: name → original key + const seen = new Map(); + + for (const [key, value] of Object.entries(envValues)) { + if (!key.startsWith('REP_')) continue; + if (key.startsWith('REP_GATEWAY_')) continue; + + let name: string; + let tier: 'public' | 'sensitive' | 'server'; + + if (key.startsWith('REP_PUBLIC_')) { + name = key.slice('REP_PUBLIC_'.length); + tier = 'public'; + } else if (key.startsWith('REP_SENSITIVE_')) { + name = key.slice('REP_SENSITIVE_'.length); + tier = 'sensitive'; + } else if (key.startsWith('REP_SERVER_')) { + name = key.slice('REP_SERVER_'.length); + tier = 'server'; + } else { + continue; + } + + const existing = seen.get(name); + if (existing) { + throw new Error( + `Variable name collision: "${key}" conflicts with "${existing}" — names must be unique across tiers after prefix stripping`, + ); + } + seen.set(name, key); + + result[tier][name] = value; + } + + return result; +} + +/** + * Read env file + process.env, merge (process.env wins), and classify. + */ +export function readAndClassify(envFilePath: string): ClassifiedVars { + const fileVars = readEnvFile(envFilePath); + + // Merge: file as base, process.env overlays + const merged: Record = { ...fileVars }; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) { + merged[key] = value; + } + } + + return classifyVariables(merged); +} diff --git a/plugins/next/src/guardrails.ts b/plugins/next/src/guardrails.ts new file mode 100644 index 0000000..21d42c2 --- /dev/null +++ b/plugins/next/src/guardrails.ts @@ -0,0 +1,92 @@ +/** + * Guardrails — secret detection for REP Next.js plugin. + * Copied from cli/src/utils/guardrails.ts (scanValue + shannonEntropy only). + * + * Implements Shannon entropy calculation and known secret format detection + * per REP-RFC-0001 §3.3. + */ + +export interface GuardrailWarning { + detectionType: 'high_entropy' | 'known_format' | 'length_anomaly'; + message: string; + context?: string; +} + +interface KnownSecretPrefix { + prefix: string; + service: string; +} + +const knownSecretPrefixes: KnownSecretPrefix[] = [ + { prefix: 'AKIA', service: 'AWS Access Key' }, + { prefix: 'ASIA', service: 'AWS Temporary Access Key' }, + { prefix: 'eyJ', service: 'JWT Token' }, + { prefix: 'ghp_', service: 'GitHub Personal Access Token' }, + { prefix: 'gho_', service: 'GitHub OAuth Token' }, + { prefix: 'ghs_', service: 'GitHub Server Token' }, + { prefix: 'ghr_', service: 'GitHub Refresh Token' }, + { prefix: 'github_pat_', service: 'GitHub Fine-Grained PAT' }, + { prefix: 'sk_live_', service: 'Stripe Secret Key' }, + { prefix: 'rk_live_', service: 'Stripe Restricted Key' }, + { prefix: 'sk-', service: 'OpenAI API Key' }, + { prefix: 'xoxb-', service: 'Slack Bot Token' }, + { prefix: 'xoxp-', service: 'Slack User Token' }, + { prefix: 'xoxs-', service: 'Slack App Token' }, + { prefix: 'SG.', service: 'SendGrid API Key' }, + { prefix: '-----BEGIN', service: 'Private Key / Certificate' }, + { prefix: 'AGE-SECRET-KEY-', service: 'age Encryption Key' }, +]; + +export function shannonEntropy(s: string): number { + if (s.length === 0) { + return 0; + } + + const freq = new Map(); + for (const char of s) { + freq.set(char, (freq.get(char) || 0) + 1); + } + + const length = s.length; + let entropy = 0.0; + + for (const count of freq.values()) { + const p = count / length; + if (p > 0) { + entropy -= p * Math.log2(p); + } + } + + return entropy; +} + +export function scanValue(value: string): GuardrailWarning[] { + const warnings: GuardrailWarning[] = []; + + for (const kp of knownSecretPrefixes) { + if (value.startsWith(kp.prefix)) { + warnings.push({ + detectionType: 'known_format', + message: `value matches known ${kp.service} format (prefix: ${kp.prefix})`, + }); + break; + } + } + + const entropy = shannonEntropy(value); + if (entropy > 4.5 && value.length > 16) { + warnings.push({ + detectionType: 'high_entropy', + message: `value has high entropy (${entropy.toFixed(2)} bits/char) — may be a secret`, + }); + } + + if (value.length > 64 && !value.includes(' ') && !value.startsWith('http')) { + warnings.push({ + detectionType: 'length_anomaly', + message: `value is ${value.length} chars with no spaces and no URL prefix — may be an encoded secret`, + }); + } + + return warnings; +} diff --git a/plugins/next/src/index.ts b/plugins/next/src/index.ts new file mode 100644 index 0000000..cd3bc69 --- /dev/null +++ b/plugins/next/src/index.ts @@ -0,0 +1,104 @@ +import React from 'react'; +import { resolve } from 'node:path'; +import { readFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { getOrCreateKeys } from './keys.js'; +import { readAndClassify } from './env.js'; +import { scanValue } from './guardrails.js'; +import { buildPayload } from './payload.js'; + +export interface RepScriptProps { + /** Path to env file, relative to project root. Default: '.env.local' */ + env?: string; + /** Enable strict mode — guardrail warnings become errors. Default: false */ + strict?: boolean; +} + +function getPackageVersion(): string { + try { + const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'); + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); + return pkg.version; + } catch { + return '0.0.0'; + } +} + +/** + * React Server Component that injects the REP ` breakout + return React.createElement('script', { + id: '__rep__', + type: 'application/json', + 'data-rep-version': version, + 'data-rep-integrity': payload.sri, + dangerouslySetInnerHTML: { __html: payload.json }, + }); +} + +export type { Keys } from './crypto.js'; +export type { ClassifiedVars } from './env.js'; +export type { PayloadResult } from './payload.js'; +export type { GuardrailWarning } from './guardrails.js'; diff --git a/plugins/next/src/keys.ts b/plugins/next/src/keys.ts new file mode 100644 index 0000000..2b1a920 --- /dev/null +++ b/plugins/next/src/keys.ts @@ -0,0 +1,26 @@ +import { generateKeys, type Keys } from './crypto.js'; + +/** + * Module-scoped singleton for ephemeral cryptographic keys. + * + * In Next.js dev, the server is a long-running Node.js process, so keys + * persist across requests within the same server lifecycle. New keys are + * generated on server restart — matching the gateway's behavior. + * + * RepScript and the session-key route handler both import this module, + * ensuring they share the same keys (encryption key used for the blob + * matches the key returned by /rep/session-key). + */ +let _keys: Keys | null = null; + +export function getOrCreateKeys(): Keys { + if (!_keys) { + _keys = generateKeys(); + } + return _keys; +} + +/** @internal For testing only. */ +export function _resetKeys(): void { + _keys = null; +} diff --git a/plugins/next/src/payload.ts b/plugins/next/src/payload.ts new file mode 100644 index 0000000..c464f9e --- /dev/null +++ b/plugins/next/src/payload.ts @@ -0,0 +1,101 @@ +import { + computeIntegrity, + encryptSensitive, + computeSRI, + goEscapeJSON, + type Keys, +} from './crypto.js'; +import type { ClassifiedVars } from './env.js'; + +export interface PayloadResult { + json: string; + scriptTag: string; + sri: string; +} + +export interface PayloadOptions { + version: string; + hotReload?: boolean; +} + +/** + * Build the REP payload JSON and script tag. + * Mirrors gateway/pkg/payload/payload.go:Build + ScriptTag. + * + * JSON field order matches Go struct serialization: + * { "public": {...}, "sensitive": "...", "_meta": {...} } + * + * All string values get Go HTML escaping (< > & → \u003c \u003e \u0026). + * + * Note: `injected_at` uses JS Date.toISOString() (millisecond precision) + * while Go uses time.RFC3339Nano (nanosecond precision). This is harmless — + * the timestamp differs per-request anyway and the SRI hash is computed + * over the actual JSON bytes produced by each implementation. + */ +export function buildPayload( + classified: ClassifiedVars, + keys: Keys, + options: PayloadOptions, +): PayloadResult { + const publicMap = classified.public; + const sensitiveMap = classified.sensitive; + const hasSensitive = Object.keys(sensitiveMap).length > 0; + + // Step 1: Compute integrity over public vars only (empty sensitive blob). + // This matches Go's payload.go — integrity is computed BEFORE encryption, + // and the integrity token is used as AAD for AES-GCM. + const integrity = computeIntegrity(publicMap, '', keys.hmacSecret); + + // Step 2: Encrypt sensitive vars if present. + let sensitiveBlob = ''; + if (hasSensitive) { + sensitiveBlob = encryptSensitive(sensitiveMap, keys.encryptionKey, integrity); + } + + // Step 3: Build the payload JSON manually to match Go's field order. + // Go's json.Marshal produces fields in struct declaration order: + // public, sensitive (omitempty), _meta + const injectedAt = new Date().toISOString(); + + // Build _meta object + const metaParts: string[] = [ + `"version":${goEscapeJSON(JSON.stringify(options.version))}`, + `"injected_at":${goEscapeJSON(JSON.stringify(injectedAt))}`, + `"integrity":${goEscapeJSON(JSON.stringify(integrity))}`, + ]; + + if (hasSensitive) { + metaParts.push(`"key_endpoint":"/rep/session-key"`); + } + + if (options.hotReload) { + metaParts.push(`"hot_reload":"/rep/changes"`); + } + + metaParts.push(`"ttl":0`); + + // Build public object with sorted keys + Go escaping + const publicSorted = Object.keys(publicMap).sort(); + const publicParts = publicSorted.map( + k => `${goEscapeJSON(JSON.stringify(k))}:${goEscapeJSON(JSON.stringify(publicMap[k]))}`, + ); + const publicJSON = `{${publicParts.join(',')}}`; + + // Assemble top-level payload + const parts: string[] = [`"public":${publicJSON}`]; + + if (hasSensitive) { + parts.push(`"sensitive":${goEscapeJSON(JSON.stringify(sensitiveBlob))}`); + } + + parts.push(`"_meta":{${metaParts.join(',')}}`); + + const json = `{${parts.join(',')}}`; + const jsonBytes = Buffer.from(json, 'utf-8'); + const sri = computeSRI(jsonBytes); + + const scriptTag = + ``; + + return { json, scriptTag, sri }; +} diff --git a/plugins/next/src/session-key.ts b/plugins/next/src/session-key.ts new file mode 100644 index 0000000..d1ec34e --- /dev/null +++ b/plugins/next/src/session-key.ts @@ -0,0 +1,44 @@ +import { getOrCreateKeys } from './keys.js'; + +/** + * Next.js App Router route handler for /rep/session-key. + * + * Usage: + * // app/api/rep/session-key/route.ts + * export { GET } from '@rep-protocol/next/session-key'; + * + * Security: + * - Returns 404 in production. The gateway serves this endpoint in prod + * with rate limiting, single-use tokens, and CORS origin checking. + * This dev-only handler has none of those hardening measures. + * - Uses standard Response (not NextResponse) to avoid coupling to + * next/server internals. + */ +export function GET(): Response { + if (process.env.NODE_ENV === 'production') { + return new Response( + JSON.stringify({ + error: 'Session key endpoint is disabled in production. Use the REP gateway.', + }), + { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + + const keys = getOrCreateKeys(); + + return new Response( + JSON.stringify({ + key: keys.encryptionKey.toString('base64'), + expires_at: new Date(Date.now() + 30_000).toISOString(), + }), + { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }, + }, + ); +} diff --git a/plugins/next/tsconfig.json b/plugins/next/tsconfig.json new file mode 100644 index 0000000..fc9103f --- /dev/null +++ b/plugins/next/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020"], + "jsx": "react-jsx", + "strict": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/plugins/next/vitest.config.ts b/plugins/next/vitest.config.ts new file mode 100644 index 0000000..5a42142 --- /dev/null +++ b/plugins/next/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + include: ['src/**/*.test.ts'], + }, +}); diff --git a/plugins/vite/package.json b/plugins/vite/package.json new file mode 100644 index 0000000..cce19f5 --- /dev/null +++ b/plugins/vite/package.json @@ -0,0 +1,55 @@ +{ + "name": "@rep-protocol/vite", + "version": "0.1.10", + "description": "Vite plugin for the Runtime Environment Protocol (REP). Injects REP environment variables during development without needing the Go gateway.", + "author": "Ruach Tech", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/ruachtech/rep", + "directory": "plugins/vite" + }, + "homepage": "https://github.com/ruachtech/rep#readme", + "keywords": [ + "vite", + "vite-plugin", + "environment-variables", + "runtime-config", + "docker", + "containers", + "frontend", + "rep" + ], + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup src/index.ts --format cjs,esm --dts", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "vite": ">=4.0.0" + }, + "dependencies": { + "dotenv": "^16.4.0" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "tsup": "^8.0.0", + "typescript": "^5.4.0", + "vite": "^5.0.0", + "vitest": "^1.6.0" + } +} diff --git a/plugins/vite/src/__tests__/crypto.test.ts b/plugins/vite/src/__tests__/crypto.test.ts new file mode 100644 index 0000000..c5c868d --- /dev/null +++ b/plugins/vite/src/__tests__/crypto.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect } from 'vitest'; +import { createDecipheriv, createHmac, createHash } from 'node:crypto'; +import { + deriveKey, + generateKeys, + canonicalize, + goEscapeJSON, + computeIntegrity, + encryptSensitive, + computeSRI, +} from '../crypto.js'; + +describe('deriveKey', () => { + it('produces deterministic output for same inputs', () => { + const ikm = Buffer.alloc(32, 0xaa); + const salt = Buffer.alloc(32, 0xbb); + const a = deriveKey(ikm, salt, 'rep-blob-encryption-v1', 32); + const b = deriveKey(ikm, salt, 'rep-blob-encryption-v1', 32); + expect(a).toEqual(b); + }); + + it('produces different output for different info strings', () => { + const ikm = Buffer.alloc(32, 0xaa); + const salt = Buffer.alloc(32, 0xbb); + const a = deriveKey(ikm, salt, 'rep-blob-encryption-v1', 32); + const b = deriveKey(ikm, salt, 'different-info', 32); + expect(a).not.toEqual(b); + }); + + it('throws for length > 32', () => { + expect(() => deriveKey(Buffer.alloc(32), Buffer.alloc(32), 'test', 33)).toThrow(); + }); + + it('matches manual HKDF-SHA256 computation', () => { + const ikm = Buffer.from('test-master-key-material-here!!!' ); + const salt = Buffer.from('test-salt-value-here-32-bytes!!!' ); + const info = 'rep-blob-encryption-v1'; + + // Manual HKDF + const prk = createHmac('sha256', salt).update(ikm).digest(); + const okm = createHmac('sha256', prk) + .update(info) + .update(Buffer.from([0x01])) + .digest(); + + const result = deriveKey(ikm, salt, info, 32); + expect(result).toEqual(okm); + }); +}); + +describe('generateKeys', () => { + it('returns 32-byte keys', () => { + const keys = generateKeys(); + expect(keys.encryptionKey).toHaveLength(32); + expect(keys.hmacSecret).toHaveLength(32); + }); + + it('generates different keys on each call', () => { + const a = generateKeys(); + const b = generateKeys(); + expect(a.encryptionKey).not.toEqual(b.encryptionKey); + expect(a.hmacSecret).not.toEqual(b.hmacSecret); + }); +}); + +describe('canonicalize', () => { + it('sorts keys alphabetically', () => { + const result = canonicalize({ Z: '1', A: '2', M: '3' }); + expect(result).toBe('{"A":"2","M":"3","Z":"1"}'); + }); + + it('produces empty object for empty map', () => { + expect(canonicalize({})).toBe('{}'); + }); + + it('applies Go HTML escaping', () => { + const result = canonicalize({ KEY: '' }); + expect(result).toContain('\\u003c'); + expect(result).toContain('\\u003e'); + expect(result).toContain('\\u0026'); + expect(result).not.toContain('<'); + expect(result).not.toContain('>'); + expect(result).not.toContain('&'); + }); +}); + +describe('goEscapeJSON', () => { + it('escapes HTML characters like Go json.Marshal', () => { + expect(goEscapeJSON('"`; + + return { json, scriptTag, sri }; +} diff --git a/plugins/vite/tsconfig.json b/plugins/vite/tsconfig.json new file mode 100644 index 0000000..d6a2b76 --- /dev/null +++ b/plugins/vite/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020"], + "strict": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/plugins/vite/vitest.config.ts b/plugins/vite/vitest.config.ts new file mode 100644 index 0000000..5a42142 --- /dev/null +++ b/plugins/vite/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + include: ['src/**/*.test.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19b17fa..d2ddc38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,39 +153,58 @@ importers: specifier: ^5.4.0 version: 5.9.3 - examples/todo-react: + plugins/next: dependencies: - '@rep-protocol/react': - specifier: workspace:* - version: link:../../adapters/react - '@rep-protocol/sdk': - specifier: workspace:* - version: link:../../sdk - react: - specifier: ^18.3.0 - version: 18.3.1 - react-dom: - specifier: ^18.3.0 - version: 18.3.1(react@18.3.1) + dotenv: + specifier: ^16.4.0 + version: 16.6.1 devDependencies: - '@rep-protocol/cli': - specifier: workspace:* - version: link:../../cli + '@types/node': + specifier: ^20.11.0 + version: 20.19.33 '@types/react': specifier: ^18.3.0 version: 18.3.28 - '@types/react-dom': - specifier: ^18.3.0 - version: 18.3.7(@types/react@18.3.28) - '@vitejs/plugin-react': - specifier: ^4.3.0 - version: 4.7.0(vite@5.4.21(@types/node@20.19.33)) + next: + specifier: ^15.0.0 + version: 15.5.13(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: + specifier: ^19.0.0 + version: 19.2.4 + react-dom: + specifier: ^19.0.0 + version: 19.2.4(react@19.2.4) + tsup: + specifier: ^8.0.0 + version: 8.5.1(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.2) typescript: specifier: ^5.4.0 version: 5.9.3 - vite: + vitest: + specifier: ^1.6.0 + version: 1.6.1(@types/node@20.19.33)(jsdom@28.1.0) + + plugins/vite: + dependencies: + dotenv: + specifier: ^16.4.0 + version: 16.6.1 + devDependencies: + '@types/node': + specifier: ^20.11.0 + version: 20.19.33 + tsup: + specifier: ^8.0.0 + version: 8.5.1(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.2) + typescript: specifier: ^5.4.0 + version: 5.9.3 + vite: + specifier: ^5.0.0 version: 5.4.21(@types/node@20.19.33) + vitest: + specifier: ^1.6.0 + version: 1.6.1(@types/node@20.19.33)(jsdom@28.1.0) sdk: devDependencies: @@ -413,18 +432,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-react-jsx-self@7.27.1': - resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.27.1': - resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-typescript@7.28.6': resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} engines: {node: '>=6.9.0'} @@ -1268,6 +1275,57 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + '@next/env@15.5.13': + resolution: {integrity: sha512-6h7Fm29+/u1WBPcPaQl0xBov7KXB6i0c8oFlSlehD+PuZJQjzXQBuYzfkM32G5iWOlKsXXyRtcMaaqwspRBujA==} + + '@next/swc-darwin-arm64@15.5.13': + resolution: {integrity: sha512-XrBbj2iY1mQSsJ8RoFClNpUB9uuZejP94v9pJuSAzdzwFVHeP+Vu2vzBCHwSObozgYNuTVwKhLukG1rGCgj8xA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@15.5.13': + resolution: {integrity: sha512-Ey3fuUeWDWtVdgiLHajk2aJ74Y8EWLeqvfwlkB5RvWsN7F1caQ6TjifsQzrAcOuNSnogGvFNYzjQlu7tu0kyWg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@15.5.13': + resolution: {integrity: sha512-aLtu/WxDeL3188qx3zyB3+iw8nAB9F+2Mhyz9nNZpzsREc2t8jQTuiWY4+mtOgWp1d+/Q4eXuy9m3dwh3n1IyQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@15.5.13': + resolution: {integrity: sha512-9VZ0OsVx9PEL72W50QD15iwSCF3GD/dwj42knfF5C4aiBPXr95etGIOGhb8rU7kpnzZuPNL81CY4vIyUKa2xvg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@15.5.13': + resolution: {integrity: sha512-3knsu9H33e99ZfiWh0Bb04ymEO7YIiopOpXKX89ZZ/ER0iyfV1YLoJFxJJQNUD7OR8O7D7eiLI/TXPryPGv3+A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@15.5.13': + resolution: {integrity: sha512-AVPb6+QZ0pPanJFc1hpx81I5tTiBF4VITw5+PMaR1CrboAUUxtxn3IsV0h48xI7fzd6/zw9D9i6khRwME5NKUw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@15.5.13': + resolution: {integrity: sha512-FZ/HXuTxn+e5Lp6oRZMvHaMJx22gAySveJdJE0//91Nb9rMuh2ftgKlEwBFJxhkw5kAF/yIXz3iBf0tvDXRmCA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@15.5.13': + resolution: {integrity: sha512-B5E82pX3VXu6Ib5mDuZEqGwT8asocZe3OMMnaM+Yfs0TRlmSQCBQUUXR9BkXQeGVboOWS1pTsRkS9wzFd8PABw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -1304,9 +1362,6 @@ packages: cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-beta.27': - resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} - '@rollup/pluginutils@5.3.0': resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} engines: {node: '>=14.0.0'} @@ -1465,6 +1520,9 @@ packages: '@sinclair/typebox@0.27.10': resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@testing-library/dom@9.3.4': resolution: {integrity: sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==} engines: {node: '>=14'} @@ -1479,18 +1537,6 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -1547,12 +1593,6 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - '@vitejs/plugin-react@4.7.0': - resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@vitest/expect@1.6.1': resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==} @@ -1864,6 +1904,9 @@ packages: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -2926,6 +2969,27 @@ packages: resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} engines: {node: '>= 10'} + next@15.5.13: + resolution: {integrity: sha512-n0AXf6vlTwGuM93Z++POtjMsRuQ9pT5v2URPciXKUQIl/EB2WjXF0YiIUxaa9AEMFaMpZlaG3KPK6i4UVnx9eQ==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + nlcst-to-string@4.0.0: resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} @@ -3135,6 +3199,10 @@ packages: resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} engines: {node: '>=4'} + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -3175,20 +3243,25 @@ packages: peerDependencies: react: ^18.3.1 + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - react-refresh@0.17.0: - resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} - engines: {node: '>=0.10.0'} - react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -3326,6 +3399,9 @@ packages: scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -3474,6 +3550,19 @@ packages: style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -4441,16 +4530,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -5051,6 +5130,32 @@ snapshots: transitivePeerDependencies: - supports-color + '@next/env@15.5.13': {} + + '@next/swc-darwin-arm64@15.5.13': + optional: true + + '@next/swc-darwin-x64@15.5.13': + optional: true + + '@next/swc-linux-arm64-gnu@15.5.13': + optional: true + + '@next/swc-linux-arm64-musl@15.5.13': + optional: true + + '@next/swc-linux-x64-gnu@15.5.13': + optional: true + + '@next/swc-linux-x64-musl@15.5.13': + optional: true + + '@next/swc-win32-arm64-msvc@15.5.13': + optional: true + + '@next/swc-win32-x64-msvc@15.5.13': + optional: true + '@oslojs/encoding@1.1.0': {} '@pagefind/darwin-arm64@1.4.0': @@ -5073,8 +5178,6 @@ snapshots: '@pagefind/windows-x64@1.4.0': optional: true - '@rolldown/pluginutils@1.0.0-beta.27': {} - '@rollup/pluginutils@5.3.0(rollup@4.58.0)': dependencies: '@types/estree': 1.0.8 @@ -5193,6 +5296,10 @@ snapshots: '@sinclair/typebox@0.27.10': {} + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + '@testing-library/dom@9.3.4': dependencies: '@babel/code-frame': 7.29.0 @@ -5216,27 +5323,6 @@ snapshots: '@types/aria-query@5.0.4': {} - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.0 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.0 - '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -5292,18 +5378,6 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@20.19.33))': - dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) - '@rolldown/pluginutils': 1.0.0-beta.27 - '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 - vite: 5.4.21(@types/node@20.19.33) - transitivePeerDependencies: - - supports-color - '@vitest/expect@1.6.1': dependencies: '@vitest/spy': 1.6.1 @@ -5754,6 +5828,8 @@ snapshots: cli-boxes@3.0.0: {} + client-only@0.0.1: {} + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -7315,6 +7391,29 @@ snapshots: neotraverse@0.6.18: {} + next@15.5.13(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@next/env': 15.5.13 + '@swc/helpers': 0.5.15 + caniuse-lite: 1.0.30001770 + postcss: 8.4.31 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + styled-jsx: 5.1.6(react@19.2.4) + optionalDependencies: + '@next/swc-darwin-arm64': 15.5.13 + '@next/swc-darwin-x64': 15.5.13 + '@next/swc-linux-arm64-gnu': 15.5.13 + '@next/swc-linux-arm64-musl': 15.5.13 + '@next/swc-linux-x64-gnu': 15.5.13 + '@next/swc-linux-x64-musl': 15.5.13 + '@next/swc-win32-arm64-msvc': 15.5.13 + '@next/swc-win32-x64-msvc': 15.5.13 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + nlcst-to-string@4.0.0: dependencies: '@types/nlcst': 2.0.3 @@ -7514,6 +7613,12 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 + postcss@8.4.31: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -7553,16 +7658,21 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + react-is@17.0.2: {} react-is@18.3.1: {} - react-refresh@0.17.0: {} - react@18.3.1: dependencies: loose-envify: 1.4.0 + react@19.2.4: {} + readdirp@4.1.2: {} readdirp@5.0.0: {} @@ -7808,6 +7918,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + scheduler@0.27.0: {} + semver@5.7.2: {} semver@6.3.1: {} @@ -8021,6 +8133,11 @@ snapshots: dependencies: inline-style-parser: 0.2.7 + styled-jsx@5.1.6(react@19.2.4): + dependencies: + client-only: 0.0.1 + react: 19.2.4 + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 80f4029..964a830 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,5 +2,6 @@ packages: - 'sdk' - 'cli' - 'adapters/*' + - 'plugins/*' - 'codemod' - 'docs' diff --git a/release-please-config.json b/release-please-config.json index a7174e2..6deb066 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -16,7 +16,9 @@ "codemod", "react", "vue", - "svelte" + "svelte", + "vite", + "next" ] } ], @@ -39,6 +41,12 @@ "adapters/svelte": { "component": "svelte" }, + "plugins/vite": { + "component": "vite" + }, + "plugins/next": { + "component": "next" + }, "gateway": { "release-type": "simple", "component": "gateway",