Something went wrong
- {error.message || "An unexpected error occurred. Try reloading the page."} + An unexpected error occurred. Reload the page to try again.
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..6d0c688 --- /dev/null +++ b/.github/workflows/release-check.yml @@ -0,0 +1,21 @@ +name: Release checks +on: + pull_request: + push: + branches: [main, master] +permissions: + contents: read +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.3 + - uses: actions/setup-node@v6.5.0 + with: + node-version: '24.19.0' + cache: npm + - run: npm install --global npm@11.9.0 + - 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/.nvmrc b/.nvmrc new file mode 100644 index 0000000..60ade1a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.19.0 diff --git a/AGENTS.md b/AGENTS.md index 37e923c..b948ff5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ Project-local skills: `.hermes/skills/` (also copied into `~/.hermes/profiles/pl ## Stack -React 19 · TanStack Start / Router · Tailwind v4 · Zustand · Zod · Node 22. +React 19 · TanStack Start / Router · Tailwind v4 · Zustand · Zod · Node 24.19 · npm 11.9.0. Dev: `npm run dev`. Typecheck: `npm run typecheck`. Test: `npm test`. Build: `npm run build`. 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/CHIEF_OF_STAFF_HANDOVER.md b/docs/CHIEF_OF_STAFF_HANDOVER.md
new file mode 100644
index 0000000..a878a05
--- /dev/null
+++ b/docs/CHIEF_OF_STAFF_HANDOVER.md
@@ -0,0 +1,90 @@
+# Chief of staff handover — Barrow and Plainly
+
+Prepared 12 September 2026. Purpose: assign crews and finish launch from the existing release work. This is an implementation brief, not a claim that either product is live or that demand is proven.
+
+## 1. Decisions and current state
+
+[Inference / recommendation] Prioritise Barrow for the first paid launch: one £19 digital customer-admin kit for plumbers. Treat price and demand as experiments. Plainly is the second launch: a free browser writing checker and skill exporter. It has no paid offer or entitlement system. Keep Socialite as a separate customer-service business; it requires ongoing client work.
+
+Tony confirms he has Stripe. Account activation, payout readiness, products, prices, tax configuration, API access and delivery integrations have NOT been inspected. No Stripe connection or credentials were available in this session.
+
+| Repo | Starting PR | Tested code commit | Delivered |
+|---|---|---|---|
+| barrow | https://github.com/anwhelan01/barrow/pull/7 | cb9546243da810c9aa0a1ed68d35ef992e99d963 | Static storefront, six administrative prompts, three illustrative examples, quick-start, private HTML/Markdown downloads, configuration checks |
+| plainly | https://github.com/anwhelan01/plainly/pull/12 | d767be63ef493bcae2f90532be60acf7d2e69163 | Standalone production server, draft persistence fixes, dialect filtering, bounded diff, optional rewrite limits, deployment guidance |
+
+Both use branch `codex/production-release-2026-09-12`. Both code commits passed local validation and GitHub CI, including browser smoke tests. See `docs/VALIDATION.md`. Subsequent handover commits are documentation changes. These PRs were opened as drafts and have not been merged by this assistant. Read current PR state before starting; preserve later work.
+
+Neither deployment, live payment, delivery email nor live xAI generation was verified. Start from these release branches, not the older default-branch prototypes. Review `AGENTS.md`, `docs/RELEASE.md`, `docs/VALIDATION.md` and this file.
+
+## 2. Crew work packets
+
+Roles below are proposed assignments for the chief of staff; no agents or people have been notified. Create one card per ID in your existing work system, attach its branch/PR and appoint one owner. Each completion needs evidence, remaining blockers and the next action. Assign separate worktrees/branches; the integration owner handles shared files.
+
+| ID / crew | Work and dependencies | Definition of done |
+|---|---|---|
+| INT-01 / integration | Review both starting PRs, preserve existing work, run release checks, integrate reviewed changes before release tags | Recorded SHAs, green checks, approved review, reproducible builds |
+| BAR-01 / product | Check all six prompts and examples against real administrative scenarios; confirm £19 experiment, scope, seller identity, terms, privacy and support details | Final versioned kit and page match; no invented testimonials, savings claims or technical plumbing advice |
+| BAR-02 / payments and delivery | Implement the Stripe route in section 3; depends on kit version and Stripe test configuration | Paid test order produces private delivery automatically; failures, duplicates and recovery pass section 5 |
+| OPS-01 / deployment | Choose existing host after checking capacity/access; configure domains, TLS, service users, backups, email and monitoring | Production URLs, restart/restore evidence, HTTPS checks, documented rollback; BAR-02 required before sales |
+| PLA-01 / product and QA | Deploy free Plainly with rewrite disabled; test drafts, Clear, Google-only rules, export and mobile | Free app works at public URL without an AI key; no paid claims |
+| PLA-02 / optional AI | Only after free release: select an account-supported model, configure persistent quota and provider budget, test actual outputs | Real rewrite, failure/timeout and quota checks pass; otherwise keep disabled |
+| QA-01 / independent release review | Check evidence against section 5, including deployed hosts rather than only localhost | Written pass/fail per gate and explicit blockers; no launch on an untested delivery path |
+| GTM-01 / distribution | Prepare one clear Barrow offer, demo and initial relevant audience/channel; agree outreach with Tony | Launch material ready; record actual visits, purchases, refunds and support effort after launch |
+
+Sequence: INT-01 and BAR-01 first; payments, infrastructure and free Plainly can then proceed in parallel. Barrow sales wait for BAR-02 + OPS-01 + QA-01. Optional AI and speculative subscriptions do not block the free Plainly release. Do not reopen the 50-pack catalogue or add new niches before validating the first kit.
+
+## 3. Stripe payment and delivery specification
+
+Recommended implementation design (not shipped): retain the static storefront and use a Stripe-hosted Payment Link. Add a small isolated fulfilment service in `services/fulfilment/` in Barrow; FastAPI plus SQLite on a single durable VPS is the proposed starting point. It must not expose or depend on the unauthenticated operator app. Use existing infrastructure only after access and capacity checks. A managed delivery integration using Tony's Stripe account is an alternative if already available and it meets the same acceptance gates; its availability and cost are unverified.
+
+Stripe documents Payment Links as Checkout-based payments with separate partner or programmatic fulfilment. A receipt or redirect alone is not proof that the buyer received a file. [Stripe post-payment documentation](https://docs.stripe.com/payment-links/post-payment).
+
+Payments crew tasks:
+
+1. Create separate test and live Product/Price/Payment Link configurations for the approved kit, GBP, one-time payment, quantity one. The current advertised price is £19; resolve tax presentation before enabling live sales. Record identifiers, not secrets. Keep unimplemented discounts and extra products disabled.
+2. Collect buyer email at checkout. Use Stripe's hosted confirmation initially with a clear delivery message. Put the live Payment Link into `BARROW_CHECKOUT_URL` only when delivery passes QA.
+3. Implement `POST /webhooks/stripe`. Verify signatures using the raw request body and endpoint-specific secret. Persist accepted work durably before responding successfully; a worker processes the queue. Reject invalid signatures. [Stripe webhook guidance](https://docs.stripe.com/webhooks).
+4. Retrieve the Checkout Session server-side and verify paid status, expected live/test mode, permitted product/price, currency, quantity and approved total/tax rules. For this paid kit, an unpaid or unexpected session grants no access. Handle completed and delayed-success events if those methods are enabled. Deduplicate concurrently by session ID and event ID. Fulfilment must work without the buyer returning to the site. [Stripe fulfilment guidance](https://docs.stripe.com/checkout/fulfillment?payment-ui=stripe-hosted).
+5. Store versioned kit files outside the public web root. Implement opaque, expiring download tokens; store token hashes, bound to the paid order and kit version. Proposed initial expiry: 48 hours, with a support reissue route. Do not use a public bucket URL or treat a supplied session ID as authorisation. Rate-limit downloads/reissue and exclude tokens from logs.
+6. Send a transactional delivery email linking to both files. Use a durable outbox, bounded retries and provider idempotency where supported; ambiguous email outcomes must be recoverable. Record paid, delivery-pending, sent and failed states. Alert an operator on exhausted retries; provide a protected resend command. No customer login is needed for this release.
+7. Track refunds/disputes for support and revoke outstanding download access when appropriate; already downloaded files cannot be recalled. Store minimum order data, with a documented retention policy. Back up the order/outbox database and private kit versions together.
+
+The service, database, email integration, webhook endpoint and download tokens above are NEW WORK, not existing features. Keep Stripe keys and signing secrets in the server's secret store; no keys are needed in the static browser build. Separate test/live secrets and select a stable Stripe API/SDK version during implementation.
+
+## 4. Inputs Tony / operations must supply
+
+| Input | Why / recipient |
+|---|---|
+| Seller/trading name and business contact details | Product crew: page, checkout and customer documents |
+| Support email and working mailbox | Product + operations: `BARROW_SUPPORT_EMAIL`, delivery problems and refunds |
+| Barrow and Plainly domain/subdomain choices, DNS and host access | Operations: HTTPS deployment; no domain has been selected here |
+| Stripe dashboard access for the payments owner, account readiness and payout check | Payments: configure test/live checkout; an existing account alone does not verify readiness |
+| Approved price, sales countries and tax treatment | Product/payments: consistent displayed and charged price; owner obtains appropriate advice where needed |
+| Transactional email provider and sender-domain access | Payments/operations: authenticated sender, delivery and retry handling |
+| Deployment secret-store access | Operations: scoped Stripe secret, webhook secret, mail credential; never paste into repo or cards |
+| Optional xAI API access, model and cost ceiling | Only PLA-02; not needed to launch either static Barrow or free Plainly |
+
+No new paid service is assumed purchased. Before committing costs, record existing hosting allocation, payment fees from Tony's account, mail/storage costs and optional model costs. Current amounts are unverified. Agree customer terms, digital delivery consent where applicable, privacy/retention and refund handling before sales; this brief does not determine Tony's legal or tax position.
+
+Existing Barrow build variables: `BARROW_SELLER_NAME`, `BARROW_SUPPORT_EMAIL`, `BARROW_CHECKOUT_URL`, `BARROW_SITE_URL`. See `.env.example`; build variables must actually be exported by the build environment.
+
+Plainly: Node 24.19.0/npm 11.9.0, `npm ci`, `npm run build`, then supervised `npm start` behind TLS on loopback. Deploy `.output/`. Keep rewrite disabled initially. Enabling later requires build flag `VITE_PLAINLY_REWRITE_ENABLED=true` plus runtime `PLAINLY_REWRITE_ENABLED`, `XAI_API_KEY`, `XAI_MODEL`, `PLAINLY_ORIGIN`, `PLAINLY_DATA_DIR`; see release guide. Default 50 attempts/day and three/minute is a usage ceiling, not a monetary guarantee. All workers must share the durable quota directory; independent replicas are unsupported by the current implementation.
+
+## 5. Launch evidence required
+
+- Clean checkout/build and current CI pass for the exact release SHA. Review is complete; deployment manifest records SHA and configuration names, without values of secrets.
+- Barrow: only `storefront-dist/` is publicly served. Private files, source and operator paths return 404. Correct checkout, seller details, mobile layout, TLS and headers verified at the actual host.
+- Stripe test-mode purchase: approved amount/currency, recorded paid order, receipt, automatic delivery email and both correct files. Closing checkout before return still delivers. Repeat/concurrent webhooks create one entitlement; invalid, unpaid, wrong-product and wrong-mode events create none. Test delayed payment paths if enabled.
+- Delivery: mail outage followed by recovery, worker restart, expired/invalid token, support reissue and refund handling tested. Restore a backup and confirm paid-but-unsent orders resume. Capture redacted order/event IDs and results.
+- Live configuration reviewed separately from test mode. Controlled legitimate live purchase with an authorised buyer confirms the live payment-to-delivery path; do not use Stripe test cards in live mode. Record refund outcome if that purchase is refunded. A sandbox pass alone is insufficient evidence of live fulfilment.
+- Plainly: `/healthz`, process restart, draft reload/Clear, dialect toggle, skill download and mobile view work; disabled Rewrite is honest. If enabled, verify actual provider calls, timeout/limit behaviour, shared quota persistence and 40 KB ingress limit.
+- Support mailbox tested. Monitoring reaches the named operator. Rollback rehearsed. Failure procedure: disable the Stripe Payment Link and publish Barrow preview to stop new sales, while retaining fulfilment for existing paid orders. Plainly can revert to the prior build with rewriting disabled.
+
+## 6. Operating handoff and portfolio boundaries
+
+Chief of staff owns the launch checklist and decides readiness from evidence. Assign someone to failed fulfilment alerts and support; this is low-maintenance, not unattended. Review early sales, customer usefulness, refund reasons and support time before building more. [Inference] A working checkout cannot establish demand; distribution remains necessary. No unsolicited automated outreach has been sent.
+
+Verified archive actions this session: canon, shelf, nocturne, cadence, harbor, mint, nightshift, narrow, gauntlet, hermes-floor and kiln are archived. Tony explicitly retained `coreys-thing` and `bullshit-radar`; both were verified active. Earlier disposition advice is superseded for those two. No repositories were deleted. Do not assign crews to unarchive the eleven or alter core infrastructure as part of this release.
+
+Use the crews' existing authorised GitHub identities. Do not copy or rely on the temporary PAT from this conversation; Tony intends to revoke it after verifying this handover. This document contains no credentials. Work packets are ready for assignment; no cards, crew notifications, merges or deployments were performed as part of this handover.
diff --git a/docs/RELEASE.md b/docs/RELEASE.md
new file mode 100644
index 0000000..4a2981a
--- /dev/null
+++ b/docs/RELEASE.md
@@ -0,0 +1,58 @@
+# Plainly release
+
+> See [Chief of staff handover](CHIEF_OF_STAFF_HANDOVER.md) for crew assignments, dependencies and deployment acceptance gates.
+
+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 24.19.0 and npm 11.9.0. Use `nvm use` and `npm install --global npm@11.9.0`, then 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.
+
+The tested runtime is pinned in `.nvmrc` and `packageManager`. Use npm 11.9.0: npm 10 on the initial GitHub runner interpreted the peer-dependency lock differently and rejected it. The CI workflow now uses the same Node/npm versions as local validation.
diff --git a/docs/REPO_DISPOSITION.md b/docs/REPO_DISPOSITION.md
new file mode 100644
index 0000000..1c1b2eb
--- /dev/null
+++ b/docs/REPO_DISPOSITION.md
@@ -0,0 +1,50 @@
+# 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. Subsequently, Tony authorised archiving eleven entries; those archive statuses were verified. No repositories were deleted.
+
+## 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 |
+
+## Original archive recommendation — subsequent decision below
+
+| 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.
+
+## Executed decision
+
+Tony retained `coreys-thing` and `bullshit-radar`; both remain active. All other eleven repositories in the original archive table were archived and verified on GitHub. Do not archive the two retained exceptions. See `CHIEF_OF_STAFF_HANDOVER.md` for the launch work.
diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md
new file mode 100644
index 0000000..7dc960f
--- /dev/null
+++ b/docs/VALIDATION.md
@@ -0,0 +1,25 @@
+# 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.
+
+Initial GitHub CI stopped at dependency installation: its bundled npm 10 rejected a peer-dependency entry which npm 11.9.0 accepted locally. The release now pins Node 24.19.0 and npm 11.9.0 in CI and the documented toolchain; subsequent runner results are available on the PR.
diff --git a/package-lock.json b/package-lock.json
index 72f33d5..392877c 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,10 @@
"typescript": "^5.7.0",
"typescript-eslint": "^8.56.1",
"vite": "^8.2.0"
+ },
+ "engines": {
+ "node": ">=24.19.0 <25",
+ "npm": "11.9.x"
}
},
"node_modules/@babel/code-frame": {
@@ -607,6 +611,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 +641,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",
@@ -2898,6 +2926,66 @@
"node": ">=14.0.0"
}
},
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.4",
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
+ "version": "0.10.2",
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
+ "version": "2.8.1",
+ "inBundle": true,
+ "license": "0BSD",
+ "optional": true
+ },
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
@@ -3953,23 +4041,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 +4957,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 +5061,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 +5084,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 +5430,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 +5470,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 +5970,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 +6469,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 +7164,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..17f1b3d 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,10 @@
"typescript": "^5.7.0",
"typescript-eslint": "^8.56.1",
"vite": "^8.2.0"
- }
+ },
+ "engines": {
+ "node": ">=24.19.0 <25",
+ "npm": "11.9.x"
+ },
+ "packageManager": "npm@11.9.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
- 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.
Drafts are saved in this browser. Clear removes the saved text. {REWRITE_ENABLED ? "Rewrite sends your draft to xAI. Avoid personal or confidential information." : "Local checks and skill export do not send your text to an AI service."} Clean enough
- No Claude-lish, brochure words, or Google-style faults in this draft.
+ No issues matched the selected rules. This does not verify accuracy or detect all writing problems.
- {error.message || "An unexpected error occurred. Try reloading the page."}
+ An unexpected error occurred. Reload the page to try again.
Something went wrong