A minimal, copy-me Next.js app showing the intended way to build applications
on top of Odoo via ORC: the user logs in with their own ORC account (OAuth 2.0),
and the app's backend reads Odoo through ORC's /mcp endpoint. This demo lists
recent Sales Orders; swap one function to make it do anything else.
The app never sees an Odoo credential, an API key, or a password. ORC holds the keys server-side, resolves them per logged-in user, and persona-gates every call. You get a deployable app; ORC keeps the security.
Browser ── "Log in with ORC" ──▶ ORC (help.opsway.com)
│ ├─ login: existing ORC session or Google SSO
│ └─ consent screen: WHO is asking (this app),
│ WHAT scopes, and WHICH environments —
│ this app pre-selects its one environment
Browser ◀── redirect back ──────── authorization code
App backend ── code + PKCE ──────▶ ORC /oauth2/token → access + refresh token
(stored in an HttpOnly cookie — JS never sees it)
Every data request:
Browser ──▶ /api/sales-orders (this app's backend)
└─ Bearer <user's token> ──▶ ORC /mcp
orc_call_tool { env, odoo_search_read } ← env-grant enforced
└──▶ Odoo (credentials held by ORC, per-user)
Why route through ORC instead of hitting Odoo directly:
- No credential sharing — keys live in ORC; rotating them never touches deployed apps.
- Per-user identity — every call runs as the logged-in user with their own permissions; two colleagues can legitimately see different data.
- Environment-scoped consent — the token is server-side bound to the environment(s) the user approved on the consent screen. This app requests exactly one; it cannot read any other Odoo instance, even if its code tried.
- Zero-trust apps — anyone can register an app (Dynamic Client Registration, no admin needed); each grant is per-(user, app), expiring, and revocable on its own.
- An ORC account (the deployment this README assumes is
https://help.opsway.com— any ORC with the OAuth connector works, setORC_URLaccordingly) with access to at least one environment. - Node 20+.
- Optional but useful: the
orcCLI to discover environment IDs and remote tools.
Every app needs its own OAuth client — that's the identity users see on the consent screen ("<your app> wants access…"), and grants are tracked per client. Don't share one client across two apps.
There are two ways to register, depending on how the app's environment is decided:
If your ORC has the Connected Apps admin page, ask a platform admin to
register the app there. They set the app's name, redirect URIs, and the
target environment(s) — and that environment is then fixed: the consent
screen shows it read-only (users don't pick from their whole list, and can't
point the app at a different Odoo). They hand you the ORC_CLIENT_ID; set it
(and the matching ORC_ENV_ID) in your config and you're done. This is the
right model for an app that's about one Odoo instance.
For a connector that legitimately spans the user's whole catalog (claude.ai style), or an ORC without the Connected Apps UI, use DCR — self-serve (RFC 7591), no admin, no client secret; it produces a public PKCE client. Here the user picks the environment at consent.
scripts/register-client.mjs does it in one call. Register every URL the app
will ever be served from, up front — local dev, the Vercel domain, and any
custom domain — so you never have to re-register (which would mint a new client
id and force every user to re-consent):
cd orc-vercel-boilerplate # this repo — the script lives here
APP_URL=http://localhost:3001 \
EXTRA_REDIRECT_URLS=https://my-app.app.opsway.com \
CLIENT_NAME="My App" \
npm run register-clientAPP_URL— the primary base URL; the redirect becomes<APP_URL>/api/auth/callback.EXTRA_REDIRECT_URLS— comma-separated additional base URLs (each gets/api/auth/callbackappended). Optional; use it to cover every host at once.CLIENT_NAME— what users see on the consent screen. Make it honest and specific (it's how someone tells your app apart from anyone else's).ORC_URL— defaults tohttps://help.opsway.com; set it for a different ORC.
It prints:
| Output | What to do with it |
|---|---|
ORC_CLIENT_ID |
put in .env.local (local) and the deployed project's env vars |
redirect_uris |
echo of what was registered — Hydra matches these character-exact |
registration_access_token + registration_client_uri |
keep private (not in the repo). They let you update this client later (RFC 7592) — e.g. add a redirect URL — without minting a new id |
Adding a URL later (a new custom domain) without a new client id — PATCH the registration instead of re-running the script:
curl -X PATCH "$REGISTRATION_CLIENT_URI" \
-H "Authorization: Bearer $REGISTRATION_ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"redirect_uris":["http://localhost:3001/api/auth/callback","https://my-app.app.opsway.com/api/auth/callback"]}'Two failure modes to know. (1)
redirect_uri … does not matchat login → the host you're serving from isn't among the registeredredirect_uris(register it, exact match incl. scheme + port). (2) On an ORC that enforces a redirect-host allowlist, a registered URL can still be refused withunauthorized_client … redirect address is not allowed— the host's zone isn't allowlisted. Onhelp.opsway.comthe allowlisted zone is*.app.opsway.com, a wildcard already pointing at Vercel, so serving from an<name>.app.opsway.comhost is allowed out of the box — no per-app operator request, no new DNS. See the callback-domain note in the deploy section;*.vercel.appand bare*.opsway.comhosts are not on that allowlist.
-
Get the code (clone, or copy the folder — nothing here is deployment-specific):
git clone https://github.com/opsway/orc-vercel-boilerplate.git cd orc-vercel-boilerplate && npm install
-
Register your OAuth client — see Register the app above. For a purely local start, the minimum is:
APP_URL=http://localhost:3001 CLIENT_NAME="My Sales Orders app" npm run register-clientCopy the printed
ORC_CLIENT_IDfor the next step. (Going to deploy? Register the Vercel + custom-domain URLs now too, viaEXTRA_REDIRECT_URLS— one registration covers every host.) -
Configure:
cp .env.example .env.local
Fill in:
ORC_CLIENT_ID— from step 2.ORC_ENV_ID— the environment (Odoo instance) this app is pinned to. Find it withorc envs(theIDcolumn), or ask your ORC admin.
-
Run and log in:
npm run dev # http://localhost:3001Click Log in with ORC. You'll authenticate on ORC (your existing session or Google SSO — this app never sees credentials), then ORC shows the consent screen, and what it looks like depends on how the app was registered:
- DCR client (the
register-client.mjsscript above) → an environment picker with this app's env pre-checked but still changeable. This is correct for a generic connector, but it means the user can point the app at a different environment. - Connected App (registered by a platform admin — see Register the app) → the environment is shown read-only, no picker: the app is locked to the environment it was registered for. This is what you want for a single-purpose app.
Allow → you're back, with a table of that environment's recent Sales Orders.
- DCR client (the
Every vercel deploy is an immutable, rollback-able version; a Git
integration is optional sugar.
npm i -g vercel
vercel # first run creates/links the project, deploys a preview
vercel --prod # production deployServe the app from
*.app.opsway.com, not*.vercel.app.help.opsway.comenforces a redirect-host allowlist that*.vercel.appis not on, so a bare vercel.app deploy can't complete login (details in the note at the end of this section).*.app.opsway.comis a wildcard that already points at Vercel and is already allowlisted — no DNS to create, no operator request to make. Just pick any unique hostname under it (e.g.my-app.app.opsway.com) and use it in place of<project>.vercel.appin every step below.
Then wire production up:
- Pick a unique
https://<name>.app.opsway.comand add it as a domain on the Vercel project (vercel domains add <name>.app.opsway.com, or the dashboard). The*.app.opsway.comwildcard already resolves to Vercel, so it goes live with no new DNS record. - Make sure
https://<name>.app.opsway.com/api/auth/callbackis on the client'sredirect_uris. If you registered it up front (recommended — see Register the app), you're done. If not, add it to the existing client (keeps the sameORC_CLIENT_ID, no re-consent) via the RFC 7592 PATCH shown in that section — don't re-run the registration script, which mints a new client id. - Set the four env vars on the Vercel project (dashboard or
vercel env add):ORC_URL,ORC_CLIENT_ID,ORC_ENV_ID, andAPP_URL=https://<name>.app.opsway.com. vercel --prodagain to pick them up. Share the URL — every colleague logs in as themselves and sees what their permissions allow.
Callback domain — the allowlist is a hard requirement, not a maybe.
help.opsway.comenforces a redirect-host allowlist.*.vercel.appis a shared hosting zone (anyone can deploy there), so it is not on the allowlist — and that is not fixable by asking the operator to allow your one<project>.vercel.apphost. A bare*.vercel.appdeploy will build, register its redirect_uri, and even reach ORC's login screen — but the callback is rejected, so login can never complete.The allowlisted zone is
*.app.opsway.com, and it's a wildcard already pointing at Vercel — so any unique hostname under it (<name>.app.opsway.com) works as a callback with zero DNS or operator steps: add it as a domain on your Vercel project, registerhttps://<name>.app.opsway.com/api/auth/callback, and setAPP_URLto it. Pick the name up front; changing it later means re-registering redirect URIs.
Preview deployments get per-deploy URLs that were never registered as redirect URLs — log in on the production URL (or register a preview URL explicitly if you need it).
The demo is deliberately one query + one table. To adapt it:
-
Change the environment — set
ORC_ENV_IDto any environment you can reach (orc envs). The login route pre-selects it on the consent screen (envsparameter inapp/api/auth/login/route.ts). -
Change the data —
lib/orc.ts→fetchSalesOrdersis a singleorc_call_toolinvocation of Odoo'sodoo_search_read. Change themodel/fields/domain/order, or add more functions alongside it. Discover what tools an environment's namespace offers:orc odoo --env '<Org>/<env>' list # tool catalog orc odoo --env '<Org>/<env>' describe odoo_search_read # arg schema
Namespaces beyond
odoo(github, jira, …) work the same way throughorcCallTool(token, '<namespace>', '<tool>', {...})— subject to the user's persona and the environment grant. -
Write data — use
odoo_create/odoo_writevia the sameorc_call_tool. Wire writes to explicit user actions only; whether a given user may write is decided by ORC (their persona), not by your code. -
Change the UI —
app/page.tsx+app/sales-orders.tsx, intentionally plain React, no UI framework. -
Rename the app —
CLIENT_NAMEat registration is what users see on the consent screen; pick something honest.
The demo is a read-only Odoo app. Real apps usually need two more things:
their own persistence (data with no home in Odoo) and a handful of
operational patterns that aren't obvious until you hit them. These are
findings from building a real app on this boilerplate
(opsway/app-sales-opportunity-studio
— Odoo via ORC for CRM/partners, Neon for its own ideas/demos/solutions). Copy
what you need.
Reach for this when the app has entities Odoo doesn't model (app-specific records, curation, cross-references, workflow state). Keep Odoo the source of truth for what already lives there; put the rest in Neon.
Provision (Vercel-managed Neon). Vercel dashboard → your project → Storage
→ Create Database → Neon → connect to the project (all environments). It
auto-injects a bundle of env vars — DATABASE_URL, DATABASE_URL_UNPOOLED,
POSTGRES_URL, POSTGRES_URL_NON_POOLING, PGHOST / PGUSER / PGPASSWORD /
PGDATABASE, NEON_*. No manual copying needed for deploys.
Client:
npm i @neondatabase/serverlessimport { neon } from '@neondatabase/serverless';
const sql = neon(dbUrl());
// Parameterized query — use .query() (see the first gotcha below):
const rows = await sql.query('SELECT * FROM ideas WHERE stage = $1', ['sold']);Three things that will bite you:
- Use
sql.query(text, params)for placeholder queries (or the tagged-templatesql`...`). Current@neondatabase/serverlessversions disable the baresql(text, params)call form as a SQL-injection foot-gun — it throws at runtime: "can now be called only as a tagged-template function … use sql.query(…)"..query()returns the rows array directly (same shape as node-postgres'.rows). This bites only in production if you develop locally against a different driver (see next bullet) — it never shows up intscor local dev. - The HTTP driver talks to Neon only. It won't connect to a local/Docker
Postgres. For offline local dev, swap drivers by connection string — use
node-postgres (
pg.Pool) against a Docker Postgres locally andneon()on Vercel, both behind onesql(text, params)helper that returns rows the same way. That avoids needing a real Neon URL just to run locally, and is why the.query()mismatch above only surfaces in prod. (Alternatively, run theneondatabase/neon_localproxy so the serverless driver itself works locally.) - One statement per call. No multi-statement scripts — keep DDL as a list of
CREATE TABLE IF NOT EXISTSstatements and run them in sequence.
Read the URL with fallbacks, so whatever Vercel injected works:
function dbUrl(): string {
const u =
process.env.DATABASE_URL?.trim() ||
process.env.POSTGRES_URL?.trim() ||
process.env.DATABASE_URL_UNPOOLED?.trim();
if (!u) throw new Error('Missing DATABASE_URL (attach Neon in Vercel → Storage)');
return u;
}Schema + seed, idempotently (no migration tool). Memoize a one-time "ready"
promise; every route awaits it. Runs at most once per warm serverless instance,
and IF NOT EXISTS makes it safe to run repeatedly.
const SCHEMA = [
`CREATE TABLE IF NOT EXISTS ideas ( id SERIAL PRIMARY KEY, title TEXT NOT NULL, ... )`,
`CREATE TABLE IF NOT EXISTS ... `,
];
let ready: Promise<void> | null = null;
export function ensureReady() {
if (!ready) ready = (async () => {
for (const ddl of SCHEMA) await sql(ddl); // one statement per call
// ...seed reference data only if empty...
})();
return ready;
}Add a tiny public health route so a plain curl proves the DB is reachable +
initialized: GET /api/health → ensureReady() then return a couple of counts.
vercel env pull
cannot retrieve Sensitive values — it writes the literal placeholder
"[SENSITIVE]". So a pulled .env.local contains DATABASE_URL="[SENSITIVE]",
and the app fails locally with "Database connection string … is not a valid
URL." — while production is fine, because it reads the real value
server-side.
- Tell: if your local
DATABASE_URLis 11 chars and starts with[, it's the placeholder, not your database. - Fix: paste the real connection string into
.env.localyourself — Vercel → Storage → your Neon store →.env.localtab / "Show secret", or the Neon console. Same applies to any var you mark Sensitive (incl. ones you add).
lib/config.ts reads APP_URL via required() at module load. Vercel imports
your route modules at build time to collect them — so if you deploy before
setting APP_URL, the build fails with a cryptic "Failed to collect page data
for /api/auth/callback."
Fall back to Vercel's stable production domain so a deploy never needs an
explicit APP_URL and the OAuth redirect still matches a registered URL:
export const APP_URL =
process.env.APP_URL?.trim().replace(/\/+$/, '') ||
(process.env.VERCEL_PROJECT_PRODUCTION_URL
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
: (() => { throw new Error('Missing APP_URL and no VERCEL_PROJECT_PRODUCTION_URL'); })());Use VERCEL_PROJECT_PRODUCTION_URL (stable) — not VERCEL_URL (per-deploy),
which changes every deploy and would never match the registered redirect.
Always pass intent. Every odoo_* tool takes a required intent
string (min 5 chars) — a one-line plain-English statement shown to the user in
ORC's activity log and used as a forcing function that measurably reduces
wrong-arg calls. The demo's fetchSalesOrders omits it (it still works), but
real apps should include it on every call:
await orcCallTool(token, 'odoo', 'odoo_search_read', {
intent: 'Listing recent sale orders for the dashboard.',
model: 'sale.order', fields: [...], limit: 25,
});Forgiving entity search (name OR email → commercial company). A naive
name ilike 'pg' misses punctuation-heavy legal names like
"P.G. Group Sp. z o.o" (lower-cased it has no pg substring). Search name OR
email, then roll each hit up to its commercial_partner_id and dedupe — so a
search by an email-domain fragment finds the account, and you offer the company
rather than individual contacts:
domain: query ? ['|', ['name','ilike',query], ['email','ilike',query]]
: [['is_company','=',true]],
fields: ['id','name','is_company','commercial_partner_id','commercial_company_name'],
// then: accountId = commercial_partner_id?.[0] ?? id; dedupe by accountId.Writes. odoo_create / odoo_write go through the same orc_call_tool.
Wire them to explicit user gestures only; whether the user may write is ORC's
persona decision, not your code's. Treat a transient error on a write as
outcome-unknown — re-read before re-firing.
ORC gates sign-in and persona-gates every call, but your app often needs a local identity (owner defaults, per-user rows, roles). The access token can be opaque or lack an email, so don't assume you can read one:
- Best-effort: decode JWT claims (
sub,ext.email); or call OIDC/userinfo— but that needs theopenidscope, which this boilerplate does not request (OAUTH_SCOPE = 'mcp offline_access'). - To get a reliable email, add
openid emailtoOAUTH_SCOPE, re-register the client with those scopes, and read the returned id_token. - For an internal tool where ORC already gates who can sign in, the simplest
model is often: treat every authenticated user uniformly (e.g. all admins) and
key per-user rows by the token
sub. Don't build a role system you can't populate.
- App Router ignores
_-prefixed folders.app/api/_diag/route.tsis non-routable (404). Name diagnostic/utility routes without the underscore. - Per-deploy URLs are protected.
https://<project>-<hash>.vercel.appreturns a 302 to a Vercel login; only the stable production alias and your custom domains are public. Health-check the stable alias. - Env changes need a redeploy.
vercel env adddoesn't affect the running deployment until youvercel --prodagain. - Porting a legacy vanilla-JS SPA? Drop it in
/publicand load it as a classic script (<Script src="/app.js" strategy="afterInteractive" />). Its top-level functions and inlineonclick=""handlers then resolve as globals — so an imperative SPA moves over verbatim without rewriting every handler as a React event.
On help.opsway.com the *.app.opsway.com wildcard already exists and points at
Vercel (see Deploy to Vercel) — you don't
need any of this. If you run your own ORC and need a fresh org-controlled
wildcard to serve apps from:
- DNS:
*.app.<your-zone>→cname.vercel-dns.com, DNS only (grey cloud). Cloudflare won't proxy a wildcard off Enterprise anyway, and Vercel must terminate TLS. - Add the domain to the Vercel project; for a wildcard cert, add the TXT record Vercel shows.
- Add each app's callback to its existing OAuth client via the RFC 7592 PATCH (see Register the app) — never re-run the registration script, which mints a new client id and forces every user to re-consent.
- PKCE public client — no client secret exists; the code exchange is bound
to a per-login verifier (
app/api/auth/login/callback). - Tokens live in an HttpOnly cookie — page JavaScript can never read them; only this app's backend forwards the access token to ORC.
- Access token ~1 h; refresh token ~30 d, rotating — after every refresh
the new refresh token must be persisted (the
/api/sales-ordersroute re-sets the cookie; seeensureFreshTokensinlib/orc.ts). Concurrent refreshes can race the rotation; losing the race just means one extra login. offline_accessis a choice — drop it fromOAUTH_SCOPEinlib/config.tsand the app holds only 1-hour tokens (users re-login via a silent redirect roughly hourly). Keep it for daily-use apps.- Logout revokes —
app/api/auth/logoutbest-effort revokes the refresh token at ORC (RFC 7009) and clears the cookie. - Two authorize-request rules (learned against a live ORC — don't remove):
resource=https://<orc-host>/mcpis required (binds the token to the right tenant host), and anaudienceparameter must not be sent (the server rejects it for DCR clients; ORC grants the audience itself).
| Symptom | Cause / fix |
|---|---|
| Consent shows the full environment list and I can pick others (even though one is pre-checked) | Your app is a generic DCR client (registered with register-client.mjs) — generic clients always get the picker. To lock it to a single environment (read-only, no picker), register it as a Connected App via ORC's platform admin instead; the admin sets the environment there and it becomes fixed. Then set ORC_CLIENT_ID to the new managed client. This is a registration change, not a code change — the boilerplate needs no extra hooks. |
unauthorized_client … redirect address is not allowed at login |
The ORC deployment enforces a redirect-host allowlist and your domain isn't on it — ask the ORC operator to add the app's exact host. |
this app was not granted access to this environment |
The user consented to different environment(s) than ORC_ENV_ID. Log out, log in again, and select the right environment on the consent screen. |
OAuth state mismatch after login |
The 10-minute login window expired or cookies were blocked mid-flow — just click Log in again. |
Table shows Could not load orders: … |
The message is ORC's real cause: persona denies the tool, the user holds no usable key on that environment, or Odoo itself errored. It's per-user — another user may succeed. |
| Session expires ~hourly | offline_access was removed from OAUTH_SCOPE, or the refresh-token rotation raced; log in again. |
Hydration warning about <body> attributes in dev |
A browser extension (ColorZilla et al.) stamping the DOM — already suppressed on <body>; harmless. |
Redirect lands on /?error=token exchange failed (400)… |
Usually redirect_uri mismatch: APP_URL must exactly match a registered redirect base (scheme, host, port). |
| File | Role |
|---|---|
scripts/register-client.mjs |
one-shot Dynamic Client Registration (RFC 7591) |
lib/config.ts |
env-driven config (ORC URL, client id, env UUID, scopes) |
lib/cookies.ts |
HttpOnly cookie plumbing (OAuth transient + token set) |
lib/orc.ts |
rotation-aware token refresh + /mcp JSON-RPC + the Odoo query |
app/api/auth/login |
starts authorization-code + PKCE (+ env pre-selection) |
app/api/auth/callback |
state check + code→token exchange |
app/api/auth/logout |
revoke + clear |
app/api/sales-orders |
the data endpoint (refresh → orc_call_tool) |
app/page.tsx, app/sales-orders.tsx |
minimal UI |
- Nothing in this repo is secret by design: the client id is a public OAuth
client identifier, and
.env.local(your actual config) is gitignored. Keep theregistration_access_tokenfrom registration out of the repo — it can modify your client registration. - Treat every deployed copy as an app your colleagues must choose to trust: the consent screen names it, scopes it, and environment-limits it — that's the contract. Don't work around it.