Open-source release containing the Max frontend, backend, and Word add-in.
frontend/— Next.js 16 app (App Router, Turbopack, React Compiler)backend/— Express API, Cloud SQL access, document processing, migrationsword-addin/— Microsoft Word task pane that talks to the same backend (seeword-addin/README.md)vanjske_datoteke/— third-party glue (WordPress plugins for the eulex.ai identity provider —eulex-mcp-oauth.phpandeulex-social-auth.php); these live on the eulex WP install, not herebackend/migrations/000_one_shot_schema.sql— one-shot schema for fresh databases;001_…,100_…,1NN_…are incremental on topscripts/deploy.sh— one-shot Cloud Run deploy from a laptopcloudbuild.yaml— managed CI pipeline alternative todeploy.sh
Install dependencies:
npm install --prefix backend
npm install --prefix frontend
npm install --prefix word-addin # only if you plan to use the add-inLocal env files:
cp backend/.env.example backend/.env
cp frontend/.env.local.example frontend/.env.localApply schema. For a fresh DB run backend/migrations/000_one_shot_schema.sql
in the SQL editor; for an existing one apply numbered migrations in order.
The most recent are:
102_align_document_edits.sql— addschange_idand FK constraints todocument_edits. Without it,[dbShim] insert on "document_edits" failed: column "change_id" of relation "document_edits" does not existsurfaces as the user-visible "Failed to record edits."103_align_with_canonical.sql— aligns the rest of the tables with000_one_shot_schema.sqlidempotently104_add_user_profile_cols.sql— extra profile columns105_auth_pair_codes.sql— required for Word add-in pairing flow
Run:
npm run dev --prefix backend # http://localhost:3001
npm run dev --prefix frontend # http://localhost:3000cd word-addin
npm install
npm run install-certs # one-time, self-signed Office cert
npm run dev # taskpane on https://localhost:3002Sideload word-addin/manifest.xml in Word, then pair from
Account → Word add-in (6-digit code, 5-minute TTL).
For production builds, npm run build inside word-addin/ emits the
bundle into frontend/public/word-addin/, served by Next.js.
- PostgreSQL (Cloud SQL in production, Supabase or local PG in dev)
- S3-compatible object storage (Cloudflare R2 in dev, GCS in production)
- At least one model provider key (see "LLM configuration" below)
- LibreOffice (DOC/DOCX → PDF conversion)
- WordPress + Ultimate Membership Pro on
eulex.aifor the identity provider;vanjske_datoteke/eulex-mcp-oauth.phpruns there as the OAuth 2.1 Authorization Server with DCR + PKCE
Backend reads provider keys from backend/.env:
GEMINI_API_KEY— Google GeminiANTHROPIC_API_KEY— Anthropic ClaudeOPENROUTER_API_KEY— OpenRouter (multi-provider)RESEND_API_KEY— Resend (email)
VLLM_BASE_URL— vLLM server base URL (e.g.https://your-vllm/v1)VLLM_API_KEY— vLLM API keyVLLM_MAIN_MODEL— primary model name (e.g.BredaAI)VLLM_LIGHT_MODEL— lightweight model for quick responses
npm run build --prefix backend
npm run build --prefix frontend
npm run lint --prefix frontendAGPL-3.0-only. See LICENSE.
The two services run on Cloud Run in mikeoss-495610 /
europe-west1:
| Service | Stable URL | Project URL (immutable) |
|---|---|---|
mike-frontend |
https://mike-frontend-516192556389.europe-west1.run.app |
https://mike-frontend-cc6nrgescq-ew.a.run.app |
mike-backend |
https://mike-backend-516192556389.europe-west1.run.app |
https://mike-backend-cc6nrgescq-ew.a.run.app |
Cloud SQL PostgreSQL is reachable both via Cloud SQL Connector (private, IAM auth) and via Cloud SQL Proxy from a laptop:
cloud-sql-proxy mikeoss-495610:europe-west1:<instance> --port 5433
psql -h 127.0.0.1 -p 5433 -U <user> -d mike# Defaults already point at the right service names + URLs; override only
# if your project differs.
export PROJECT_ID=mikeoss-495610
export REGION=europe-west1
export FRONTEND_URL=https://mike-frontend-cc6nrgescq-ew.a.run.app
export BACKEND_URL=https://mike-backend-cc6nrgescq-ew.a.run.app
./scripts/deploy.sh # backend + frontend + add-in
./scripts/deploy.sh frontend # frontend (also rebuilds add-in into it)
./scripts/deploy.sh backend # backend only
./scripts/deploy.sh addin # only rebuild Word add-in into frontend/publicdeploy.sh frontend:
- Rebuilds the Word add-in with
ADDIN_URL=$FRONTEND_URLandAPI_BASE_URL=$BACKEND_URL, emitting intofrontend/public/word-addin/. - Writes
frontend/.env.productionwith the twoNEXT_PUBLIC_*vars so Next.js inlines them at build time, then deletes it on exit. - Runs
gcloud run deploy --source frontendwith--set-build-env-varsas a belt-and-suspenders fallback.
deploy.sh backend: plain gcloud run deploy --source backend.
cloudbuild.yaml exists for gcloud builds submit --config=cloudbuild.yaml.
It does the same docker build with explicit --build-arg NEXT_PUBLIC_API_BASE_URL=... for the frontend image.
This section records the non-obvious failures hit in production and how they were resolved, so the next person hitting the same symptom doesn't have to re-derive the cause from logs.
Symptom
[dbShim] insert on "document_edits" failed:
column "change_id" of relation "document_edits" does not exist
UI showed Edit failed on every edit_document LLM tool call.
Cause Cloud SQL diverged from 000_one_shot_schema.sql. New columns
(change_id, FK to documents) were missing.
Fix Wrote two idempotent migrations and applied them via Cloud SQL Proxy:
backend/migrations/102_align_document_edits.sqlbackend/migrations/103_align_with_canonical.sql
cloud-sql-proxy mikeoss-495610:europe-west1:<instance> --port 5433 &
psql -h 127.0.0.1 -p 5433 -U <user> -d mike -f backend/migrations/102_align_document_edits.sql
psql -h 127.0.0.1 -p 5433 -U <user> -d mike -f backend/migrations/103_align_with_canonical.sqlSymptom
Access to fetch at 'https://mike-backend.../docx?version_id=...' from
origin 'https://mike-frontend...' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested
resource.
Cause Two layers of trouble:
- Cloud Run scaled
mike-backendto zero. Cold-start latency tripped a timeout and Cloud Run's edge returned a 502 with no CORS headers, so Chrome blamed CORS for what was really a 5xx. - Even on warm starts, an unhandled exception in the route handler produced a default Express error page without CORS headers.
Fix
gcloud run services update mike-backend --min-instances=1to keep one warm instance.backend/src/index.tsnow installs:- a global middleware that writes
Access-Control-Allow-Origin/Access-Control-Allow-Credentials/Vary: Originon every response before routing, so they survivethrowin a route; - a catch-all 404 inside Express (so unknown paths still get CORS);
- a global error handler that converts thrown errors into JSON 500 with CORS headers attached;
process.on('unhandledRejection' | 'uncaughtException', …)for log breadcrumbs.
- a global middleware that writes
backend/src/routes/documents.tsgot diagnostic logging at thedocuments not found/GCS miss/version not foundbranches so the next "404 looks like CORS" is one log line away from triage.
Symptom
GET http://localhost:3001/projects net::ERR_FAILED
… No 'Access-Control-Allow-Origin' header …
across /projects, /chat, /user/profile, /user/mcp-servers,
/auth/pair/start. Production frontend was hitting localhost.
Cause (the deep one) Next.js 16 + Turbopack inlines
process.env.NEXT_PUBLIC_* at build time. process.env beats .env*
files in Next's resolution order. Cloud Run "deploy from source" seeded
the build container with empty NEXT_PUBLIC_API_BASE_URL (matching
the ARG declared in the Dockerfile) which then preempted Next's own
.env.production loader. The build log lied — it said
Environments: .env.production (file was loaded) yet the bundle
shipped a="".trim()||"http://localhost:3001" (process.env value won,
empty string beat the file's URL).
The earlier failure mode that motivated the current Dockerfile was the
same family: ENV NEXT_PUBLIC_API_BASE_URL=${ARG} always exported the
ARG, even as "" when no --build-arg was passed, so Next inlined ""
in client code → API_BASE = "" → every call routed to the frontend
origin (fetch("/chat")) and got back the Next.js HTML page.
Fix (frontend/Dockerfile, stage 2)
- Don't
ENVthe build args directly — only mirror them into.env.production.localif the ARG is non-empty (so.env.productionfromdeploy.shwins when the ARG isn't passed). - Right before
npm run build, source.env.production[.local]into the shell environment withset -a; . ./.env.production; set +aso the values are present in the actualprocess.envNext.js evaluates at inline time. Belt-and-suspenders error if the var is empty. - Print both files for build-log diagnosability.
The RUN step that did the env-presence check originally used $$f /
$$found shell loop variables. Docker expands $WORD in RUN as
build-time ARG/ENV before /bin/sh ever runs, so unknown names became
empty and the guard always failed. Replaced the loop with two literal
grep -q checks.
Fix (scripts/deploy.sh)
log "Frontend: writing .env.production with NEXT_PUBLIC_API_BASE_URL=$BACKEND_URL"
cat > frontend/.env.production <<EOF
NEXT_PUBLIC_API_BASE_URL=$BACKEND_URL
NEXT_PUBLIC_BACKEND_URL=$BACKEND_URL
EOF
trap 'rm -f frontend/.env.production' EXIT
gcloud run deploy "$FRONTEND_SERVICE" --source frontend \
--region "$REGION" --project "$PROJECT_ID" \
--set-build-env-vars "NEXT_PUBLIC_API_BASE_URL=${BACKEND_URL},NEXT_PUBLIC_BACKEND_URL=${BACKEND_URL}" \
--quietFix (frontend/.gcloudignore + root .gcloudignore)
gcloud run deploy --source frontend packages paths relative to
frontend/, so the root !frontend/.env.production rule never matches.
Added frontend/.gcloudignore with explicit !.env.production and a
failsafe !**/.env.production at the root.
Verification Curl one of the deployed chunks and look for the backend URL string:
curl -s 'https://mike-frontend-516192556389.europe-west1.run.app/_next/static/chunks/<hash>.js' \
| grep -oE 'https://mike-backend[a-z0-9.-]+\.run\.app' | head -1
# → https://mike-backend-cc6nrgescq-ew.a.run.appIf the chunk only contains localhost:3001, the env var did not get
inlined and Step 3 above broke again.
Symptom Cloud Build step RUN npm ci failed with peer-dep mismatch:
next pinned @swc/helpers@0.5.15, next-intl's transitive
@swc/core required >=0.5.17. Local npm install was lenient,
Cloud Build's npm ci was not.
Fix Added an overrides block to frontend/package.json:
"overrides": {
"@swc/helpers": "0.5.21"
}then regenerated package-lock.json (rm package-lock.json && npm install)
and committed the new lockfile. After that npm ci resolves cleanly.
Symptom "Client Not Registered" on second connect attempt to
mcp.eulex.ai. First connect worked; after the eulex MCP service
scaled to zero and back, the same client_id was rejected.
Cause mcp.eulex.ai (a Python Cloud Run service) stored DCR
registrations in process memory. Scale-to-zero wiped them; the
returning instance had no record of the client_id Max was sending.
Fix (workarounds shipped in this repo)
backend/src/routes/mcpServers.ts— when token exchange fails withinvalid_client, automatically re-run DCR with the same metadata and retry once before surfacing the error to the UI.- New
POST /user/mcp-servers/:id/reauththat nukes storedoauth_metadata/oauth_tokens/oauth_code_verifierand forces a full re-registration on next use. - Frontend
frontend/src/app/(pages)/account/mcp/page.tsxexposes a "Reset OAuth" button that calls the above endpoint.
Pending (eulex side) Migrate DCR + token storage in
mcp.eulex.ai from in-memory to Firestore or Cloud SQL. Until then,
expect the occasional re-pair after long idle.
Symptom Going from Max → eulex.ai → "Sign in with Google" →
back to /authorize failed with "PKCE required. code_challenge with
S256 method is mandatory." Direct username/password login worked.
Cause Nextend Social Login's redirect_to handling truncated query
parameters. Max's PKCE code_challenge, client_id and state
disappeared between the round trip.
Fix (vanjske_datoteke/eulex-mcp-oauth.php) Before redirecting an
unauthenticated user to /signin/, the plugin now stashes the entire
$_GET payload into a WordPress transient keyed by a short
mcp_resume token (15-min TTL), and only that token rides the
redirect_to URL. On the way back, /authorize recognises
?mcp_resume=…, restores the original query string from the transient,
and bounces to a clean /authorize?<full original query> so the
consent screen and downstream PKCE checks see the real values.
A complementary HttpOnly cookie (eulex_mcp_resume) was also added as
a failsafe for the case where even the mcp_resume query param is
stripped — but the read-side counterpart is not yet wired into the
plugin (see "Pending" below).
Symptom Clicking Delete on an MCP connector flashed a confirm dialog and dismissed it before the click registered. Network never fired.
Cause Browser's native confirm() was being suppressed by Chrome's
"don't show again" auto-block for the origin.
Fix Replaced every window.confirm() in the MCP connector flow
with the in-app modal frontend/src/app/components/modals/confirm-dialog.tsx.
Symptom Logged in as bplese@gmail.com, deleting a chat returned
Owner-only action.
Cause WordPress had two distinct accounts that both authenticated through the same Google identity in different flows:
| WP user_id | DB user_id | |
|---|---|---|
| 1 | info@eulex.ai |
fc4b351d-… |
| 35 | bplese@gmail.com |
9944fd29-… |
A handful of chats and one document still had user_id = fc4b351d-… from before the second account was created.
Fix (one-time SQL) Reassigned ownership for the affected rows
(backend/reassign.mjs) — 2 chats, 1 document moved from
fc4b351d-… to 9944fd29-…. The Test Project was kept owned by
info@eulex.ai and bplese@gmail.com was added to its shared_with
JSONB array via backend/share_project.mjs. The first version of
share_project.mjs failed with COALESCE types jsonb and text[] cannot be matched; the working query uses || to_jsonb(ARRAY[$2::text])
and the ? operator for membership.
Pending (eulex side) Update vanjske_datoteke/eulex-social-auth.php
to look up an existing WP user by email before creating a new one
on Google login, so the two-accounts-for-one-human case can't recur.
Symptom Account → Word add-in generates the pairing code OK; the
add-in side in Word renders the 6 input boxes but underneath them
shows a wall of raw HTML starting with
<!DOCTYPE html><html lang="hr">... and references to
/_next/static/chunks/.... Pairing never completes.
Cause Production taskpane.bundle.js was built without
API_BASE_URL set in the build environment. dotenv-webpack with
systemvars: true in word-addin/webpack.config.js only inlines what
it can read at build time, so process.env.API_BASE_URL was replaced
with undefined. The runtime fallback in
word-addin/src/taskpane/lib/auth.ts:
function defaultApiBase(): string {
if (typeof window !== "undefined" && window.location?.origin) {
return window.location.origin;
}
return "http://localhost:3001";
}
export const API_BASE = process.env.API_BASE_URL?.trim() || defaultApiBase();then returned window.location.origin, which is the frontend
Cloud Run URL (the bundle is served from <frontend>/word-addin/...).
The add-in then POSTed /auth/pair/redeem and /auth/pair/start to
mike-frontend-..., which has no such Express routes; Next.js answered
with the SPA's HTML shell, the add-in's res.text() rendered as the
visible HTML soup.
Fix scripts/deploy.sh build_addin() now exports
API_BASE_URL=$BACKEND_URL for the webpack build and verifies the URL
appears in taskpane.bundle.js before letting the deploy proceed:
ADDIN_URL="$FRONTEND_URL" API_BASE_URL="$BACKEND_URL" npm run build
# …
if ! grep -q "$BACKEND_URL" frontend/public/word-addin/taskpane.bundle.js; then
echo "ERROR: taskpane.bundle.js does not contain BACKEND_URL=$BACKEND_URL" >&2
exit 1
fiAfter the next ./scripts/deploy.sh frontend (or addin) the bundle
will hard-code the backend URL. Word users must remove the add-in and
re-sideload the manifest (or restart Word) so Office picks up the new
bundle hash.
Verification From a shell:
curl -s https://mike-frontend-516192556389.europe-west1.run.app/word-addin/taskpane.bundle.js \
| grep -oE 'https://mike-backend[a-z0-9.-]+\.run\.app' | head -1
# → https://mike-backend-cc6nrgescq-ew.a.run.appIn Word, the pairing screen should now show 6 empty input boxes and
nothing else; entering the code should hit
POST https://mike-backend-…/auth/pair/redeem (visible in Edge/Chrome
Office DevTools → Network) and return JSON.
| # | Where | What |
|---|---|---|
| 1 | vanjske_datoteke/eulex-mcp-oauth.php (uncommitted +14 lines) |
Add the eulex_mcp_resume cookie read path in the /authorize handler so the failsafe cookie added in the working copy actually gets used. Then upload to the eulex.ai WP install. |
| 2 | vanjske_datoteke/eulex-social-auth.php |
Look up existing WP user by email before creating a new one on Google/LinkedIn login. Prevents future "two accounts for one human" cases like fix #8 above. |
| 3 | mcp.eulex.ai (Python service, not in this repo) |
Persist DCR registrations + tokens to Firestore or Cloud SQL so connector pairing survives scale-to-zero. Max-side workarounds in fix #5 stay as a safety net. |
| 4 | Word add-in | Verify fix #9 end-to-end with a real Word client after the next frontend deploy. |