From 0f71109f7029d9a155533165750e5837264488d7 Mon Sep 17 00:00:00 2001 From: Ola Adebayo Date: Mon, 16 Mar 2026 21:23:39 +0000 Subject: [PATCH 1/5] feat(vite): add @rep-protocol/vite plugin for dev-time REP injection Eliminates the need to run the Go gateway during development. The plugin injects the same ' }); + 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..a94acbf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,11 +156,11 @@ importers: examples/todo-react: dependencies: '@rep-protocol/react': - specifier: workspace:* - version: link:../../adapters/react + specifier: ^0.1.7 + version: 0.1.10(@rep-protocol/sdk@0.1.10)(react@18.3.1) '@rep-protocol/sdk': - specifier: workspace:* - version: link:../../sdk + specifier: ^0.1.7 + version: 0.1.10 react: specifier: ^18.3.0 version: 18.3.1 @@ -169,8 +169,11 @@ importers: version: 18.3.1(react@18.3.1) devDependencies: '@rep-protocol/cli': + specifier: ^0.1.7 + version: 0.1.10 + '@rep-protocol/vite': specifier: workspace:* - version: link:../../cli + version: link:../../plugins/vite '@types/react': specifier: ^18.3.0 version: 18.3.28 @@ -178,7 +181,7 @@ importers: specifier: ^18.3.0 version: 18.3.7(@types/react@18.3.28) '@vitejs/plugin-react': - specifier: ^4.3.0 + specifier: ^4.7.0 version: 4.7.0(vite@5.4.21(@types/node@20.19.33)) typescript: specifier: ^5.4.0 @@ -187,6 +190,28 @@ importers: specifier: ^5.4.0 version: 5.4.21(@types/node@20.19.33) + 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: jsdom: @@ -1304,6 +1329,19 @@ packages: cpu: [x64] os: [win32] + '@rep-protocol/cli@0.1.10': + resolution: {integrity: sha512-qyj4bvfX8m/u/M3ESKjbsAC8Kp7/8E8VJSBBW5cQgbqkCtJNXmp5JOd1s7u1BBz60z8nnrjw3fBMfdv9+cv9og==} + hasBin: true + + '@rep-protocol/react@0.1.10': + resolution: {integrity: sha512-lbXjEVKQPM1A7zyQW05u0W6mND0YF6tMUL45faKtSlMY+ztAc0DOw5pKXXGFqTWRza9S+Kxh2YcjwcWKAfzkFA==} + peerDependencies: + '@rep-protocol/sdk': 0.1.10 + react: '>=16.8.0' + + '@rep-protocol/sdk@0.1.10': + resolution: {integrity: sha512-u03UlBoyrmNeQ2+nmncjdecGSw3byIInAQXN4B0CdSVoV/5HW/zOnsBwRT+1Bei8AgaOmgbJoaNpM0s7fBcrAg==} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -5073,6 +5111,22 @@ snapshots: '@pagefind/windows-x64@1.4.0': optional: true + '@rep-protocol/cli@0.1.10': + dependencies: + ajv: 8.18.0 + chalk: 5.6.2 + commander: 12.1.0 + dotenv: 16.6.1 + glob: 13.0.6 + js-yaml: 4.1.1 + + '@rep-protocol/react@0.1.10(@rep-protocol/sdk@0.1.10)(react@18.3.1)': + dependencies: + '@rep-protocol/sdk': 0.1.10 + react: 18.3.1 + + '@rep-protocol/sdk@0.1.10': {} + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/pluginutils@5.3.0(rollup@4.58.0)': 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..c93d72d 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -16,7 +16,8 @@ "codemod", "react", "vue", - "svelte" + "svelte", + "vite" ] } ], @@ -39,6 +40,9 @@ "adapters/svelte": { "component": "svelte" }, + "plugins/vite": { + "component": "vite" + }, "gateway": { "release-type": "simple", "component": "gateway", From d012856ddba7f5f796fc574ff727181ad8a282b9 Mon Sep 17 00:00:00 2001 From: Ola Adebayo Date: Tue, 17 Mar 2026 01:46:11 +0000 Subject: [PATCH 2/5] feat(next): add @rep-protocol/next plugin for dev-time REP injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RepScript is a React Server Component that injects the REP \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..41a6fdf --- /dev/null +++ b/plugins/next/src/__tests__/session-key.test.ts @@ -0,0 +1,120 @@ +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'); + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); + } 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..5b80874 --- /dev/null +++ b/plugins/next/src/guardrails.ts @@ -0,0 +1,92 @@ +/** + * Guardrails — secret detection for REP Vite 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..aa0362e --- /dev/null +++ b/plugins/next/src/payload.ts @@ -0,0 +1,96 @@ +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. + * Matches gateway/pkg/payload/payload.go:Build + ScriptTag exactly. + * + * JSON field order must match Go struct serialization: + * { "public": {...}, "sensitive": "...", "_meta": {...} } + * + * All string values get Go HTML escaping (< > & → \u003c \u003e \u0026). + */ +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..fe5342a --- /dev/null +++ b/plugins/next/src/session-key.ts @@ -0,0 +1,45 @@ +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', + 'Access-Control-Allow-Origin': '*', + }, + }, + ); +} 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/pnpm-lock.yaml b/pnpm-lock.yaml index a94acbf..1f70429 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,42 +153,33 @@ importers: specifier: ^5.4.0 version: 5.9.3 - examples/todo-react: + plugins/next: dependencies: - '@rep-protocol/react': - specifier: ^0.1.7 - version: 0.1.10(@rep-protocol/sdk@0.1.10)(react@18.3.1) - '@rep-protocol/sdk': - specifier: ^0.1.7 - version: 0.1.10 - 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: ^0.1.7 - version: 0.1.10 - '@rep-protocol/vite': - specifier: workspace:* - version: link:../../plugins/vite + '@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.7.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@18.3.1(react@19.2.4))(react@19.2.4) + react: + specifier: ^19.0.0 + version: 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: - specifier: ^5.4.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) plugins/vite: dependencies: @@ -438,18 +429,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'} @@ -1293,6 +1272,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==} @@ -1329,22 +1359,6 @@ packages: cpu: [x64] os: [win32] - '@rep-protocol/cli@0.1.10': - resolution: {integrity: sha512-qyj4bvfX8m/u/M3ESKjbsAC8Kp7/8E8VJSBBW5cQgbqkCtJNXmp5JOd1s7u1BBz60z8nnrjw3fBMfdv9+cv9og==} - hasBin: true - - '@rep-protocol/react@0.1.10': - resolution: {integrity: sha512-lbXjEVKQPM1A7zyQW05u0W6mND0YF6tMUL45faKtSlMY+ztAc0DOw5pKXXGFqTWRza9S+Kxh2YcjwcWKAfzkFA==} - peerDependencies: - '@rep-protocol/sdk': 0.1.10 - react: '>=16.8.0' - - '@rep-protocol/sdk@0.1.10': - resolution: {integrity: sha512-u03UlBoyrmNeQ2+nmncjdecGSw3byIInAQXN4B0CdSVoV/5HW/zOnsBwRT+1Bei8AgaOmgbJoaNpM0s7fBcrAg==} - - '@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'} @@ -1503,6 +1517,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'} @@ -1517,18 +1534,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==} @@ -1585,12 +1590,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==} @@ -1902,6 +1901,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'} @@ -2964,6 +2966,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==} @@ -3173,6 +3196,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} @@ -3219,14 +3246,14 @@ packages: 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'} @@ -3512,6 +3539,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'} @@ -4479,16 +4519,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 @@ -5089,6 +5119,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': @@ -5111,24 +5167,6 @@ snapshots: '@pagefind/windows-x64@1.4.0': optional: true - '@rep-protocol/cli@0.1.10': - dependencies: - ajv: 8.18.0 - chalk: 5.6.2 - commander: 12.1.0 - dotenv: 16.6.1 - glob: 13.0.6 - js-yaml: 4.1.1 - - '@rep-protocol/react@0.1.10(@rep-protocol/sdk@0.1.10)(react@18.3.1)': - dependencies: - '@rep-protocol/sdk': 0.1.10 - react: 18.3.1 - - '@rep-protocol/sdk@0.1.10': {} - - '@rolldown/pluginutils@1.0.0-beta.27': {} - '@rollup/pluginutils@5.3.0(rollup@4.58.0)': dependencies: '@types/estree': 1.0.8 @@ -5247,6 +5285,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 @@ -5270,27 +5312,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 @@ -5346,18 +5367,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 @@ -5808,6 +5817,8 @@ snapshots: cli-boxes@3.0.0: {} + client-only@0.0.1: {} + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -7369,6 +7380,29 @@ snapshots: neotraverse@0.6.18: {} + next@15.5.13(react-dom@18.3.1(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: 18.3.1(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 @@ -7568,6 +7602,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 @@ -7607,16 +7647,22 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-dom@18.3.1(react@19.2.4): + dependencies: + loose-envify: 1.4.0 + react: 19.2.4 + scheduler: 0.23.2 + 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: {} @@ -8075,6 +8121,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/release-please-config.json b/release-please-config.json index c93d72d..6deb066 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -17,7 +17,8 @@ "react", "vue", "svelte", - "vite" + "vite", + "next" ] } ], @@ -43,6 +44,9 @@ "plugins/vite": { "component": "vite" }, + "plugins/next": { + "component": "next" + }, "gateway": { "release-type": "simple", "component": "gateway", From 4246583ec5508fe7bdd726bd84368e98d60c97b6 Mon Sep 17 00:00:00 2001 From: Ola Adebayo Date: Tue, 17 Mar 2026 01:57:39 +0000 Subject: [PATCH 3/5] docs: add Vite and Next.js plugin docs, update CI for plugins Docs: - installation.mdx: add build-tool plugins section (Vite + Next.js) - development.mdx: add plugin as Option A (recommended), renumber others - frameworks/react.mdx: add Vite plugin tab to dev mode section - examples/todo-react.mdx: update to show Vite plugin workflow - examples/nextjs-proxy.mdx: update file tree and dev section for RepScript CI: - sdk.yml: add plugins/** to path filters so plugin changes trigger CI - release-sdk.yml: add vite/next to recover-stale-releases tag map and publish-npm steps --- .github/workflows/release-sdk.yml | 22 +++++- .github/workflows/sdk.yml | 2 + .../content/docs/examples/nextjs-proxy.mdx | 23 ++++++- docs/src/content/docs/examples/todo-react.mdx | 26 ++++--- docs/src/content/docs/frameworks/react.mdx | 26 ++++--- docs/src/content/docs/guides/development.mdx | 69 ++++++++++++++++++- docs/src/content/docs/guides/installation.mdx | 53 ++++++++++++++ examples/nextjs-proxy/package.json | 8 +-- plugins/next/package.json | 4 -- plugins/vite/package.json | 4 -- 10 files changed, 198 insertions(+), 39 deletions(-) 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/docs/src/content/docs/examples/nextjs-proxy.mdx b/docs/src/content/docs/examples/nextjs-proxy.mdx index 630a2fc..ea75e42 100644 --- a/docs/src/content/docs/examples/nextjs-proxy.mdx +++ b/docs/src/content/docs/examples/nextjs-proxy.mdx @@ -31,8 +31,10 @@ The Next.js server is never exposed directly. All traffic flows through the gate - examples/nextjs-proxy/ - src/ - app/ - - layout.tsx + - layout.tsx Includes RepScript for dev-time injection - page.tsx Server component shell + - api/rep/session-key/ + - route.ts Dev-only session key endpoint - components/ - EnvDisplay.tsx Client component — reads REP vars - IntegrityBanner.tsx Verifies payload was not modified in transit @@ -192,14 +194,29 @@ See [`examples/nextjs-csr-embedded/k8s/`](https://github.com/ruachtech/rep/tree/ -## CLI dev mode (no Docker) +## Local development + +### With `@rep-protocol/next` (recommended) + +The project includes `RepScript` in `app/layout.tsx` and a session-key route handler. No gateway needed for development: + +```bash +cp .env.example .env.local # if you haven't already +npm run dev +``` + +Open `http://localhost:3000`. `RepScript` injects the REP payload during `next dev` and returns `null` in production. + +### With the CLI gateway + +If you need full gateway fidelity: ```bash # Terminal 1 — run Next.js dev server npm run dev # starts on :3000 # Terminal 2 — run REP gateway in proxy mode -npx @rep-protocol/cli dev --proxy http://localhost:3000 --env-file .env.local +npx @rep-protocol/cli dev --proxy http://localhost:3000 --env .env.local ``` Open `http://localhost:8080`. diff --git a/docs/src/content/docs/examples/todo-react.mdx b/docs/src/content/docs/examples/todo-react.mdx index 2f0faa9..d8ccdc9 100644 --- a/docs/src/content/docs/examples/todo-react.mdx +++ b/docs/src/content/docs/examples/todo-react.mdx @@ -30,27 +30,31 @@ A complete React todo app demonstrating all REP features. Source code at [`examp ## Running locally -### With the CLI +### With the Vite plugin (recommended) + +The project includes `@rep-protocol/vite` in `vite.config.ts`. No gateway needed for development: ```bash -# From the repo root +cd examples/todo-react pnpm install +pnpm dev +``` -# Validate the manifest -cd examples/todo-react -npx @rep-protocol/cli validate +Open `http://localhost:5173`. The Vite plugin reads `.env.local` and injects the REP payload automatically. + +### With the CLI gateway -# Generate TypeScript types -npx @rep-protocol/cli typegen +If you need full gateway fidelity (rate-limited session keys, CORS origin checking): -# Start Vite dev server +```bash +# Terminal 1: Start Vite dev server pnpm dev -# In another terminal: start REP gateway proxy -npx @rep-protocol/cli dev --proxy http://localhost:5173 +# Terminal 2: Start REP gateway proxy +pnpm dev:gateway ``` -Open `http://localhost:8080` to see the app with REP-injected variables. +Open `http://localhost:3000` (the gateway port). ### With Docker diff --git a/docs/src/content/docs/frameworks/react.mdx b/docs/src/content/docs/frameworks/react.mdx index 9090dde..514d798 100644 --- a/docs/src/content/docs/frameworks/react.mdx +++ b/docs/src/content/docs/frameworks/react.mdx @@ -99,22 +99,30 @@ Both hooks subscribe to the gateway's SSE stream when `--hot-reload` is enabled. ## Development mode -Without the gateway running, `useRep()` returns `undefined`. Two approaches: +Without the gateway running, `useRep()` returns `undefined`. Three approaches: + + ```bash + npm install -D @rep-protocol/vite + ``` + + ```ts + // vite.config.ts + import { repPlugin } from '@rep-protocol/vite'; + + export default defineConfig({ + plugins: [react(), repPlugin()], + }); + ``` + + The plugin reads `.env.local` and injects the REP payload during `vite dev` — no gateway needed. Just run `npm run dev` as normal. + ```tsx const apiUrl = useRep('API_URL', 'http://localhost:3000'); ``` - - Add to your `index.html` during development: - ```html - - ``` - ```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..aa9bb99 100644 --- a/docs/src/content/docs/guides/development.mdx +++ b/docs/src/content/docs/guides/development.mdx @@ -7,7 +7,70 @@ 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 `