From ce33a494c341e04af26776a80d6aa4de0fb11e09 Mon Sep 17 00:00:00 2001 From: Tony Whelan <135849587+anwhelan01@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:49:49 +0100 Subject: [PATCH 1/3] Prepare standalone release with bounded optional rewriting --- .env.example | 16 +- .github/workflows/release-check.yml | 20 +++ .gitignore | 2 + README.md | 2 + app-env.json | 1 + deploy/nginx.conf | 14 ++ docs/RELEASE.md | 54 +++++++ docs/REPO_DISPOSITION.md | 46 ++++++ docs/VALIDATION.md | 23 +++ package-lock.json | 105 ++++++------ package.json | 10 +- scripts/grok-pwa-plugin.test.mjs | 21 +-- scripts/release-smoke.mjs | 35 ++++ scripts/with-app-env.mjs | 3 +- server/middleware/00-release.ts | 21 +++ server/middleware/grok-pwa.ts | 111 ------------- server/virtual-grok-og-identity.d.ts | 13 -- src/components/desk.tsx | 32 ++-- src/lib/error-component.tsx | 5 +- src/lib/plainly/diff.ts | 4 + src/lib/plainly/lint.ts | 5 +- src/lib/plainly/quota.server.ts | 32 ++++ src/lib/plainly/release.test.ts | 47 ++++++ src/lib/plainly/rewrite-provider.server.ts | 38 +++++ src/lib/plainly/rewrite.ts | 55 ++----- src/routes/__root.tsx | 7 - vite.config.ts | 179 +-------------------- 27 files changed, 474 insertions(+), 427 deletions(-) create mode 100644 .github/workflows/release-check.yml create mode 100644 app-env.json create mode 100644 deploy/nginx.conf create mode 100644 docs/RELEASE.md create mode 100644 docs/REPO_DISPOSITION.md create mode 100644 docs/VALIDATION.md create mode 100644 scripts/release-smoke.mjs create mode 100644 server/middleware/00-release.ts delete mode 100644 server/middleware/grok-pwa.ts delete mode 100644 server/virtual-grok-og-identity.d.ts create mode 100644 src/lib/plainly/quota.server.ts create mode 100644 src/lib/plainly/release.test.ts create mode 100644 src/lib/plainly/rewrite-provider.server.ts diff --git a/.env.example b/.env.example index 1db623b..26c779b 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,15 @@ -# Server-only. Never prefix with VITE_. The rewrite button uses this. -# Get a key at https://console.x.ai +# Local checker and skill export work with every value below unset. +# Build-time flag: controls visibility of the optional Rewrite button. +VITE_PLAINLY_REWRITE_ENABLED=false +# Runtime opt-in. Requires all remaining settings before any provider call. +PLAINLY_REWRITE_ENABLED=false XAI_API_KEY= +# Set an exact model ID available to your xAI account; no guessed default. +XAI_MODEL= +# Persistent LOCAL volume, shared by all workers. Do not use ephemeral/serverless storage. +PLAINLY_DATA_DIR=/var/lib/plainly +PLAINLY_DAILY_REWRITES=50 +# Exact browser origin, e.g. https://your-real-domain (no trailing slash). +PLAINLY_ORIGIN= +NITRO_HOST=127.0.0.1 +NITRO_PORT=8085 diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml new file mode 100644 index 0000000..d2e8a16 --- /dev/null +++ b/.github/workflows/release-check.yml @@ -0,0 +1,20 @@ +name: Release checks +on: + pull_request: + push: + branches: [main, master] +permissions: + contents: read +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - run: npm ci + - run: npm run verify:release + - run: npx playwright install --with-deps chromium + - run: node scripts/release-smoke.mjs diff --git a/.gitignore b/.gitignore index 343f832..834f9f7 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ coverage/ # Hermes personal overrides AGENTS.override.md .hermes/*.local.yaml + +.plainly-data/ diff --git a/README.md b/README.md index 1bd7c33..7d51f10 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Plainly +> **Release update:** Read [the release guide](docs/RELEASE.md) first. It supersedes the prototype deployment and revenue claims below. +

diff --git a/app-env.json b/app-env.json new file mode 100644 index 0000000..871b623 --- /dev/null +++ b/app-env.json @@ -0,0 +1 @@ +{"VITE_AUTH_ENABLED":"false"} diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..f54fe2e --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,14 @@ +# Insert in the TLS server block for your real domain. +# TLS certificates / DNS are provisioned outside this repository. +client_max_body_size 40k; +add_header X-Content-Type-Options nosniff always; +add_header Referrer-Policy strict-origin-when-cross-origin always; +add_header X-Frame-Options DENY always; +location / { + proxy_pass http://127.0.0.1:8085; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 40s; +} diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 0000000..41a309d --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,54 @@ +# Plainly release + +This release is a free local-in-the-browser writing checker and skill exporter. It has no checkout or paid entitlement claims. A future paid pack requires its own value proposition and fulfilment; it is not represented as shipped here. + +## Build and run + +Node 22.18+. From a clean checkout: + +```sh +npm ci +npm run typecheck +npm test +npm run test:product +npm run build +NITRO_HOST=127.0.0.1 NITRO_PORT=8085 npm start +``` + +The production output is `.output/` with the Node server preset. No auth database or Grok App Builder environment is needed for the public app. Put the Node process behind your TLS reverse proxy; `deploy/nginx.conf` includes a request-body ceiling. `/healthz` returns `ok`. + +Do not expose the development server publicly. Keep the production Node listener on loopback and use a process supervisor to restart it after failures. Preserve the previous `.output/` for rollback. + +## Optional AI rewrite + +Rewrite is off by default on both client and server. Local checking and skill export do not require a provider key. To enable: + +1. Set `VITE_PLAINLY_REWRITE_ENABLED=true` when building the client. +2. At runtime set `PLAINLY_REWRITE_ENABLED=true`, `XAI_API_KEY`, `XAI_MODEL`, `PLAINLY_ORIGIN` and `PLAINLY_DATA_DIR`. +3. `XAI_MODEL` must be a model available to that account which supports chat JSON responses. There is deliberately no guessed model default. +4. `PLAINLY_ORIGIN` is the exact HTTPS browser origin without a trailing slash. Cross-origin POSTs are rejected when rewriting is enabled. +5. Give the service user a persistent local directory such as `/var/lib/plainly`. All worker processes must use that same filesystem directory. Independent replicas or serverless deployments are not supported by this budget implementation. + +The quota reserves a call before contacting the provider. Default: 50 attempts per UTC day across the deployment and no more than three per minute. Failures consume attempts. `PLAINLY_DAILY_REWRITES` accepts 1–1000. Requests are limited to 8,000 characters and 3,500 output tokens; provider requests time out after 30 seconds and are not retried. Provider pricing determines cost: this is an attempt/token ceiling, not a fixed-pound spending guarantee. Set an account-level spend limit too if available. + +Quota state contains counters only. Damaged state and lock contention fail closed. If the process is killed while holding the lock, stop all workers before removing the stale `quota.lock` directory; preserve `quota.json`, then restart. Never routinely delete quota state to resolve a limit. Use a single durable host with all workers sharing the directory. + +The reverse proxy must enforce its 40 KB body limit, including chunked requests. The app's Content-Length check is additional protection, not a substitute for that ingress limit. Test a real rewrite with the configured account before enabling it publicly. Live xAI calls were not performed during source validation because no provider credentials were supplied. + +## User-facing behaviour + +- Drafts persist in browser localStorage and survive reload. Clear saves an empty draft. +- The Google-only setting filters out Plainly-only rules. +- Style scores are heuristics, not fact checking or AI-origin detection. +- Rewrites show a diff against the submitted text. Applying a result after the source changed is refused. +- Large diffs use a bounded whole-document fallback rather than allocating an unbounded quadratic matrix. +- The UI explains that pressing Rewrite sends the draft to xAI; local checks and skill export do not. +- Google Fonts is used for typography. Site hosting sees ordinary requests; text is not sent to the font provider by the application. No analytics are included. + +## Release gate + +Source tests, production build, local production HTTP checks and browser checks are recorded in `VALIDATION.md`. DNS, certificates, real deployment availability, provider access and optional rewrite spend are deployment checks. A successful build alone is not proof of those external integrations. + +## Runtime independence + +The release configuration no longer injects the Grok App Builder extension script or preview authentication into product pages. `app-env.json` records the no-auth default in source control. Retained template utility tests use isolated identity fixtures, rather than assuming the product has no branding. diff --git a/docs/REPO_DISPOSITION.md b/docs/REPO_DISPOSITION.md new file mode 100644 index 0000000..39ac430 --- /dev/null +++ b/docs/REPO_DISPOSITION.md @@ -0,0 +1,46 @@ +# Repository disposition — 12 September 2026 + +Decision basis: Tony wants a small portfolio of low-maintenance side-hustle products. These are recommendations about active commercial effort, not a deletion instruction. No repositories were archived or deleted in this release. + +## Build now + +| Repository | Role | +|---|---| +| barrow | One digital customer-admin pack, static storefront, external paid delivery | +| plainly | Free browser-based writing checker and skill exporter; optional bounded rewrite | + +## Archive from the active side-hustle portfolio + +| Repository | Reason to drop from active commercial work | +|---|---| +| canon | Verification and commercial metrics are simulated; paid checkout is absent. Preserve its curated data before any eventual deletion. | +| shelf | Unmonetised directory with an ongoing traffic/editorial requirement. | +| nocturne | Audio production and publishing job, not low-maintenance fulfilment. | +| cadence | Daily creator output and fan-management workload. | +| bullshit-radar | Editorial desk requiring continuous judgement and publishing. | +| harbor | Useful archive search, but no compelling paid offer established. | +| mint | Overlapping offer-generation tool; retain export/pricing components if useful. | +| nightshift | Overlapping content generation with unfinished paid entitlement; use exported content internally if needed. | +| coreys-thing | Overlapping business-launch desk. Walk-In covers the more relevant consulting workflow. | +| narrow | Another business-launch desk, not a distinct customer product with proven demand. | +| gauntlet | Internal validation framework; preserve useful prompts, then archive the app. | +| hermes-floor | Overlapping marketing orchestration demo, not a near-term sellable product. | +| kiln | Thin sales-pipeline scaffold; Socialite is the more concrete implementation. | + +These are not all proven byte-identical duplicates. Similar purpose is the reason for consolidation; do not discard unique code or data on the assumption that it is copied elsewhere. + +## Preserve, but keep outside this release + +| Repository/group | Why | +|---|---| +| socialite + scran-away-demo-2 | Local website-service sales lane and demo. Potential revenue, but entails customer work. | +| walk-in | Internal consulting field kit; useful to Tony's core work. | +| brief, two, lurk, x-ops, grok-bots | Internal prompt/distribution tools. Consolidate later only after checking actual usage. | +| reelwright, music-matters | Existing creative workflows; do not sacrifice them to this side-hustle cleanup. | +| cordon, scam-alertuk-chad, shrike | Distinct security/research projects. Not immediate passive-product releases. | +| timesight, meridian, quiet-alpha, quiet-alpha-v2, quiet-draw, night-desk | Research/personal/teaching tools; no validated customer-ready financial product asserted. | +| ask-ebbi-uat, chadai, pbs-plan-generator-v2 | Separate specialist products with their own release and review requirements. | +| kanban-surface, collab-mem, collab-mem-old, kanban-surface-archive, whatsapp-brett-bridge, hrm-gears, 3to5er-agentos, titan | Core infrastructure, active projects or retained history. Do not treat as expendable side-hustle experiments. | +| Other salvaged repos and archives | Not nominated for deletion on this evidence. Confirm unique assets and dependencies first. | + +The throwaway `pat-write-test-20260827-163611` can be reviewed for housekeeping separately; no deletion has been performed. The empty `vertical-experts-agentos` and `stunning-2026` repositories had no code in the retrieved inventory, but may retain useful issues/history which this snapshot audit did not inspect. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md new file mode 100644 index 0000000..71f8e52 --- /dev/null +++ b/docs/VALIDATION.md @@ -0,0 +1,23 @@ +# Validation — 12 September 2026 + +Scope: source release candidate, tested locally on Linux with Node 24.19.0. Not a live deployment attestation. + +| Check | Result | +|---|---| +| Clean dependency installation (`npm ci --ignore-scripts --no-audit --no-fund`) | Passed after repairing the lockfile | +| TypeScript (`npm run typecheck`) | Passed | +| Existing full suite (`npm test`) | Passed: 149 script tests and 29 TypeScript tests | +| Product checks (`npm run test:product`) | Passed: 9 tests, including 5 existing linter tests | +| Production Node build (`npm run build`) | Passed | +| Production dependencies (`npm audit --omit=dev`) | 0 reported vulnerabilities at check time; this is not a security guarantee | +| Production HTTP | Homepage 200, health endpoint 200, nosniff header, no injected builder extension | +| Browser smoke | Draft survives reload; Clear remains empty after reload; rewrite disabled by default; skill download starts; no browser exceptions | +| Layout | Desktop exercised at 1280px, mobile at 390px; mobile screenshot inspected; no horizontal overflow | + +The default Playwright CDN browser download failed in this environment. Local browser validation used a Chromium binary obtained from the @sparticuz/chromium npm package, unpacked in temporary storage. No such dependency was added to this product. The smoke script accepts `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` for a preinstalled browser; CI uses Playwright's normal Chromium installation. + +Provider behaviour was exercised with fake structured responses, malformed output and provider errors. Quota tests cover burst/daily ceilings, subsequent calls, next-day rollover, clock rollback and corrupt state. Large-diff preservation and Google-only filtering have regression coverage. + +Not verified: a real xAI account/model, public DNS/TLS, service supervisor, live provider pricing, production merchant transactions (there is no checkout in Plainly), multi-host quota sharing. Optional live AI rewriting stays disabled until configured and tested by the operator. + +The inherited template suite initially failed because it depended on an ignored app-env file, actual product branding as a generic test fixture, and generated PWA assets. The config default is now committed, generic tests use isolated fixture directories, and the removed builder runtime is checked as absent from release config. No product test failures were suppressed. diff --git a/package-lock.json b/package-lock.json index 72f33d5..134fb9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,10 +1,10 @@ { - "name": "app-builder-workspace", + "name": "plainly", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "app-builder-workspace", + "name": "plainly", "dependencies": { "@electric-sql/pglite": "^0.5.4", "@hookform/resolvers": "^5.7.0", @@ -78,6 +78,9 @@ "typescript": "^5.7.0", "typescript-eslint": "^8.56.1", "vite": "^8.2.0" + }, + "engines": { + "node": ">=22.18.0" } }, "node_modules/@babel/code-frame": { @@ -607,6 +610,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -620,6 +640,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/js": { "version": "9.39.5", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", @@ -3953,23 +3980,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -4886,6 +4896,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -4966,7 +5000,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/fast-diff": { @@ -4989,7 +5023,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -5335,9 +5369,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "funding": [ { "type": "github", @@ -5375,13 +5409,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "devOptional": true, - "license": "MIT" - }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -5882,18 +5909,6 @@ } } }, - "node_modules/nitro/node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "dev": true, - "license": "BlueOak-1.0.0", - "optional": true, - "peer": true, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/nitro/node_modules/unstorage": { "version": "2.0.0-alpha.7", "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-2.0.0-alpha.7.tgz", @@ -6393,7 +6408,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -7088,7 +7103,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" diff --git a/package.json b/package.json index 5202f1f..af83855 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "scripts": { "dev": "node scripts/with-app-env.mjs vite dev --host 0.0.0.0 --port 8080", - "build": "node scripts/with-app-env.mjs vite build && npm run db:migrate", + "build": "node scripts/with-app-env.mjs vite build", "db:migrate": "node scripts/migrate.mjs", "build:dev": "node scripts/with-app-env.mjs vite build --mode development", "preview": "node scripts/with-app-env.mjs vite preview", @@ -16,7 +16,10 @@ "check:auth": "node scripts/check-auth-invariant.mjs", "test": "node --test 'scripts/**/*.test.mjs' && node --experimental-strip-types --test src/lib/app-data/app-data.test.ts src/lib/auth/gate-identity.test.ts src/lib/plainly/lint.test.ts", "lint": "eslint .", - "format": "prettier --write ." + "format": "prettier --write .", + "start": "node .output/server/index.mjs", + "test:product": "node --experimental-strip-types --test src/lib/plainly/*.test.ts", + "verify:release": "npm run typecheck && npm test && npm run test:product && npm run build" }, "dependencies": { "@electric-sql/pglite": "^0.5.4", @@ -91,5 +94,8 @@ "typescript": "^5.7.0", "typescript-eslint": "^8.56.1", "vite": "^8.2.0" + }, + "engines": { + "node": ">=22.18.0" } } diff --git a/scripts/grok-pwa-plugin.test.mjs b/scripts/grok-pwa-plugin.test.mjs index 69b32de..66cf516 100644 --- a/scripts/grok-pwa-plugin.test.mjs +++ b/scripts/grok-pwa-plugin.test.mjs @@ -6,9 +6,9 @@ import { fileURLToPath } from "node:url"; import test from "node:test"; import { appNameFromHost, - createHeadInjector, + createHeadInjector as rawCreateHeadInjector, grokXCreatorHeadTags, - injectGrokPwaHead, + injectGrokPwaHead as rawInjectGrokPwaHead, isDocumentPath, isInstallQuery, publicAppHost, @@ -19,6 +19,10 @@ import { } from "./grok-pwa-shared.mjs"; import { renderInstallPage } from "./grok-pwa-plugin.mjs"; +const EMPTY_IDENTITY_ROOT = mkdtempSync(join(tmpdir(), "pwa-fixture-")); +const injectGrokPwaHead = (html, options = {}) => rawInjectGrokPwaHead(html, { cwd: EMPTY_IDENTITY_ROOT, ...options }); +const createHeadInjector = (options = {}) => rawCreateHeadInjector({ cwd: EMPTY_IDENTITY_ROOT, ...options }); + const TEMPLATE_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); test("injects before ", () => { @@ -486,16 +490,13 @@ test("renders the manifest with the per-app name", () => { test("vite config keeps the nitro serverDir wiring", () => { const viteConfig = readFileSync(join(TEMPLATE_ROOT, "vite.config.ts"), "utf8"); assert.match(viteConfig, /serverDir:\s*"\.\/server"/); - assert.match(viteConfig, /grokPwaPlugin\(\)/); + assert.doesNotMatch(viteConfig, /grokPwaPlugin\(\)/); }); -test("nitro middleware and its bundled assets exist", () => { - const middleware = readFileSync(join(TEMPLATE_ROOT, "server/middleware/grok-pwa.ts"), "utf8"); - assert.match(middleware, /install-page\.html\?raw/); - assert.match(middleware, /virtual:grok-og-identity/); - readFileSync(join(TEMPLATE_ROOT, "scripts/install-page.html")); - readFileSync(join(TEMPLATE_ROOT, "public/__grok/icon-180.png")); - readFileSync(join(TEMPLATE_ROOT, "public/__grok/install/styles.css")); +test("release config uses a local favicon without external builder scripts", () => { + readFileSync(join(TEMPLATE_ROOT, "public/favicon.svg")); + const config = readFileSync(join(TEMPLATE_ROOT, "vite.config.ts"), "utf8"); + assert.doesNotMatch(config, /grokPwaPlugin/); }); test("vite plugin bakes og identity as a virtual module", () => { diff --git a/scripts/release-smoke.mjs b/scripts/release-smoke.mjs new file mode 100644 index 0000000..88aa64e --- /dev/null +++ b/scripts/release-smoke.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { setTimeout as pause } from 'node:timers/promises'; +import { chromium } from 'playwright'; +const server=spawn(process.execPath,['.output/server/index.mjs'],{env:{...process.env,NITRO_HOST:'127.0.0.1',NITRO_PORT:'8185',PLAINLY_REWRITE_ENABLED:'false'},stdio:['ignore','pipe','pipe']}); +let log='';server.stdout.on('data',x=>log+=x);server.stderr.on('data',x=>log+=x); +let browser; +try{ + let ready=false; + for(let i=0;i<80;i++){try{const r=await fetch('http://127.0.0.1:8185/healthz');if(r.ok){ready=true;break;}}catch{}await pause(100);} + assert.ok(ready,'Server did not start: '+log); + const response=await fetch('http://127.0.0.1:8185/'); + assert.equal(response.status,200);assert.equal(response.headers.get('x-content-type-options'),'nosniff'); + const html=await response.text();assert.doesNotMatch(html,/grok-app-builder\/extensions|auth\/get-session/); + browser=await chromium.launch({headless:true,executablePath:process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined,args:['--no-sandbox','--disable-dev-shm-usage','--single-process','--no-zygote','--disable-gpu']}); + const page=await browser.newPage({viewport:{width:1280,height:900}});const errors=[]; + page.on('pageerror',e=>errors.push(e.message)); + await page.goto('http://127.0.0.1:8185/'); + const draft=page.getByRole('textbox',{name:'Draft to lint'}); + await draft.fill('A robust seamless tapestry.'); + await page.getByRole('tab',{name:'Google',exact:true}).click(); + await page.waitForTimeout(100); + await page.reload();await page.waitForFunction(()=>document.querySelector('textarea')?.value==='A robust seamless tapestry.');assert.equal(await draft.inputValue(),'A robust seamless tapestry.'); + await page.getByRole('button',{name:'Clear',exact:true}).click(); + await page.reload();await page.waitForFunction(()=>document.querySelector('textarea')?.value==='');assert.equal(await draft.inputValue(),''); + assert.equal(await page.getByRole('button',{name:/AI rewrite off/}).isDisabled(),true); + await page.getByRole('link',{name:'Skill',exact:true}).click(); + const download=page.waitForEvent('download');await page.getByRole('button',{name:'Download',exact:true}).click(); + assert.match((await download).suggestedFilename(),/SKILL\.md$/); + await page.setViewportSize({width:390,height:844});await page.goto('http://127.0.0.1:8185/'); + assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)); + await page.screenshot({path:'/tmp/plainly_release_mobile.png',fullPage:true}); + assert.deepEqual(errors,[]); + console.log('PASS: production HTTP and headers; no builder script; draft persistence; Clear persistence; rewrite disabled; skill download; mobile overflow; no browser exceptions.'); +} finally {if(browser)await browser.close();server.kill('SIGTERM');} diff --git a/scripts/with-app-env.mjs b/scripts/with-app-env.mjs index e48dfaf..81b1e8e 100644 --- a/scripts/with-app-env.mjs +++ b/scripts/with-app-env.mjs @@ -56,7 +56,8 @@ export function readAppEnv(root) { try { return parseAppEnv(readFileSync(join(root, APP_ENV_REL_PATH), "utf8")); } catch { - return {}; + try { return parseAppEnv(readFileSync(join(root, "app-env.json"), "utf8")); } + catch { return {}; } } } diff --git a/server/middleware/00-release.ts b/server/middleware/00-release.ts new file mode 100644 index 0000000..ce66c19 --- /dev/null +++ b/server/middleware/00-release.ts @@ -0,0 +1,21 @@ +interface ReleaseEvent { url: URL; req: Request } +export default async function releaseMiddleware(event: ReleaseEvent, next: () => unknown | Promise) { + if (event.url.pathname === '/healthz') return new Response('ok\n', { headers: { 'content-type':'text/plain', 'cache-control':'no-store' } }); + if (event.req.method === 'POST') { + const origin = event.req.headers.get('origin'); + const allowed = process.env.PLAINLY_ORIGIN; + // Optional AI use requires a single configured public origin. + if (process.env.PLAINLY_REWRITE_ENABLED === 'true' && (!allowed || origin !== allowed)) return new Response('Forbidden', {status:403}); + const length = Number(event.req.headers.get('content-length') ?? 0); + if (!Number.isFinite(length) || length > 40000) return new Response('Request too large', {status:413}); + } + const response = await next(); + if (!(response instanceof Response)) return response; + const headers = new Headers(response.headers); + headers.set('X-Content-Type-Options','nosniff'); + headers.set('X-Frame-Options','DENY'); + headers.set('Referrer-Policy','strict-origin-when-cross-origin'); + headers.set('Permissions-Policy','camera=(), microphone=(), geolocation=()'); + if(event.req.method!=='GET') headers.set('Cache-Control','no-store'); + return new Response(response.body,{status:response.status,statusText:response.statusText,headers}); +} diff --git a/server/middleware/grok-pwa.ts b/server/middleware/grok-pwa.ts deleted file mode 100644 index 1575de8..0000000 --- a/server/middleware/grok-pwa.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Deployed-app (Nitro) half of the platform PWA chrome. Auto-registered as - * global h3 middleware because vite.config.ts sets `serverDir: "./server"` — - * without that option Nitro v3 never scans this directory. - * - * - `?install=1&platform=ios` on a document path → the Home Screen tutorial, - * bundled into the server build via `?raw` (the public/ directory is CDN - * static output on Vercel and not readable from the function). - * - `/__grok/manifest.webmanifest` → per-app-named manifest (kept out of - * public/ so this dynamic response is the only one). - * - Other HTML documents → stream-inject PWA + OG head tags at ``. - * OG identity is baked via `virtual:grok-og-identity` at `vite build` - * (this function cannot read `src/lib/og/site.json` or `public/og.jpg`). - * This must be a middleware transforming `next()`: h3 discards the `response` - * runtime hook's return value, and `render:html` does not exist in Nitro v3. - */ -import installPageTemplate from "../../scripts/install-page.html?raw"; -import { grokOgIdentity } from "virtual:grok-og-identity"; -import { - acceptsHtml, - createHeadInjector, - isDocumentPath, - isInstallQuery, - renderInstallPageHtml, - renderWebManifest, -} from "../../scripts/grok-pwa-shared.mjs"; - -interface GrokPwaEvent { - url: URL; - req: { method: string; headers: Headers }; -} - -function requestHost(event: GrokPwaEvent): string { - return ( - event.req.headers.get("x-forwarded-host") ?? event.req.headers.get("host") ?? event.url.host - ); -} - -function injectHeadStreaming(response: Response, host: string): Response { - const injector = createHeadInjector({ - host, - site: grokOgIdentity.site, - }); - const transformed = response.body!.pipeThrough( - new TransformStream({ - transform(chunk, controller) { - for (const out of injector.push(chunk)) controller.enqueue(out); - }, - flush(controller) { - for (const out of injector.flush()) controller.enqueue(out); - }, - }), - ); - const headers = new Headers(response.headers); - headers.delete("content-length"); - return new Response(transformed, { - status: response.status, - statusText: response.statusText, - headers, - }); -} - -export default async function grokPwaMiddleware( - event: GrokPwaEvent, - next: () => unknown | Promise, -): Promise { - const method = (event.req.method ?? "GET").toUpperCase(); - if (method !== "GET") return next(); - - const path = event.url.pathname; - const urlWithQuery = path + event.url.search; - - if (path === "/__grok/manifest.webmanifest" || path === "/__grok/manifest.json") { - return new Response(renderWebManifest(requestHost(event)), { - headers: { - "content-type": "application/manifest+json; charset=utf-8", - "cache-control": "no-cache", - }, - }); - } - - if ( - isInstallQuery(urlWithQuery) && - isDocumentPath(path) && - acceptsHtml(event.req.headers.get("accept")) - ) { - const html = renderInstallPageHtml(installPageTemplate, { - host: requestHost(event), - url: urlWithQuery, - }); - return new Response(html, { - headers: { - "content-type": "text/html; charset=utf-8", - "cache-control": "no-cache", - }, - }); - } - - if (!isDocumentPath(path)) return next(); - - const result = await next(); - if ( - result instanceof Response && - result.body && - String(result.headers.get("content-type") ?? "").includes("text/html") && - !result.headers.get("content-encoding") - ) { - return injectHeadStreaming(result, requestHost(event)); - } - return result; -} diff --git a/server/virtual-grok-og-identity.d.ts b/server/virtual-grok-og-identity.d.ts deleted file mode 100644 index 2baed2d..0000000 --- a/server/virtual-grok-og-identity.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -declare module "virtual:grok-og-identity" { - export const grokOgIdentity: { - site: { - title?: string; - description?: string; - type?: string; - card?: string; - image?: string; - banner?: string; - color?: string; - }; - }; -} diff --git a/src/components/desk.tsx b/src/components/desk.tsx index d223198..b1a1b23 100644 --- a/src/components/desk.tsx +++ b/src/components/desk.tsx @@ -21,6 +21,8 @@ import type { Dialect, Finding } from "@/lib/plainly/types"; import { cn } from "@/lib/utils"; const STORAGE_KEY = DRAFT_STORAGE_KEY; +const REWRITE_ENABLED = import.meta.env.VITE_PLAINLY_REWRITE_ENABLED === "true"; +const DRAFT_LIMIT = 20000; export function Desk() { const [text, setText] = useState(SAMPLES[0]!.text); @@ -32,28 +34,29 @@ export function Desk() { const [notes, setNotes] = useState([]); const [copied, setCopied] = useState<"draft" | "rewrite" | null>(null); const areaRef = useRef(null); - const hydrated = useRef(false); + const [hydrated, setHydrated] = useState(false); + const [rewriteSource, setRewriteSource] = useState(""); useEffect(() => { try { const saved = localStorage.getItem(STORAGE_KEY); - if (saved) setText(saved); + if (saved !== null) setText(saved.slice(0, DRAFT_LIMIT)); } catch { /* ignore */ } - hydrated.current = true; + setHydrated(true); }, []); useEffect(() => { - if (!hydrated.current) return; + if (!hydrated) return; try { localStorage.setItem(STORAGE_KEY, text); } catch { /* ignore */ } - }, [text]); + }, [text, hydrated]); - const result = useMemo(() => lint(text), [text]); + const result = useMemo(() => lint(text, dialect), [text, dialect]); const counts = countBySeverity(result.findings); const overLimit = text.length > REWRITE_MAX_CHARS; @@ -68,6 +71,8 @@ export function Desk() { } async function runRewrite() { + if (!REWRITE_ENABLED || rewriting) return; + const source = text; if (overLimit) { toast.error(`Keep the draft under ${REWRITE_MAX_CHARS.toLocaleString()} characters.`); return; @@ -83,6 +88,7 @@ export function Desk() { toast.error(response.error); return; } + setRewriteSource(source); setRewritten(response.rewritten); setNotes(response.notes); setPane("rewrite"); @@ -105,6 +111,7 @@ export function Desk() { function applyRewrite() { if (!rewritten) return; + if (text !== rewriteSource) { toast.error("Your draft changed. Run Rewrite again before replacing it."); return; } setText(rewritten); setPane("findings"); toast.success("Rewrite is now the draft."); @@ -114,8 +121,7 @@ export function Desk() {

- Paste AI writing. The desk marks Claude-lish as you type, then rewrites it - to Google-style English without changing the facts. + Paste a draft to check its wording locally. Scores are style heuristics, not an AI detector or a fact check. Review any AI rewrite before using it.

Dialect @@ -182,6 +188,7 @@ export function Desk() {