-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMODULE_PLATFORM_INTEGRATION_GUIDE.txt
More file actions
404 lines (336 loc) · 20.5 KB
/
Copy pathMODULE_PLATFORM_INTEGRATION_GUIDE.txt
File metadata and controls
404 lines (336 loc) · 20.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
================================================================
STRUCTUM — MODULE ↔ PLATFORM INTEGRATION GUIDE
(Auth / Session / Token) — for Estimate & Schedule modules
Written: 2026-06-09 | Source of truth: StructumAI-api + StructumAI-web
================================================================
WHO THIS IS FOR
---------------
You are building/finishing a Structum sub-module (Estimate or Schedule) and
must connect it to the main platform so a logged-in user can move from the
platform into your module WITHOUT logging in again or seeing errors.
This guide has TWO halves:
PART 1–3 = HOW THE PLATFORM AUTH WORKS + HOW TO CONNECT A MODULE (read first)
PART 4–6 = THE EXACT CODE/FIX to put in your module (copy from the Plans
module, which is already working in production)
The Plans module is the reference implementation. Everything below was verified
by reading the real StructumAI-api and StructumAI-web source.
================================================================
PART 1 — THE THREE SYSTEMS AND HOW THEY CONNECT
================================================================
1) StructumAI-api (the backend, e.g. staging-api.structumai.build)
- Owns login. SIGNS the JWT. Verifies tokens. Owns the Postgres DB.
- This is the ONLY place the real JWT secret lives.
2) StructumAI-web (the main platform UI, e.g. staging.structumai.build)
- The user logs in here. It calls the API, gets the token, and SETS a
shared cookie named `token` on domain `.structumai.build`.
- Has a site-wide password gate (SITE_PASSWORD).
3) Your module (Plans / Estimate / Schedule)
- Does NOT log anyone in. It READS the shared `token` cookie and VERIFIES
it with the SAME secret the API used to sign it. Then it trusts the
user/tenant from the token and serves data scoped to that tenant.
The connection in one line:
web logs in → sets `token` JWT cookie on `.structumai.build` →
your module (same domain) reads that cookie → verifies it with the shared
JWT_SECRET → gets { userId, tenantId, email }.
================================================================
PART 2 — THE PLATFORM AUTH CONTRACT (exact values — do not guess)
================================================================
Confirmed from StructumAI-api source:
A) JWT
- Signed in: src/services/auth.service.ts (signJwt) with `jwt.sign(...)`.
- SECRET env var name: **JWT_SECRET** (HS256 / HMAC-SHA256, symmetric).
- PAYLOAD CLAIMS (exact, camelCase):
userId = user UUID
tenantId = tenant UUID
email = user email
(No roles, no name, no issuer/audience in the token. `iat`/`exp` auto-added.)
- Lifetime: JWT_EXPIRES_IN, default "7d".
- Verify in API: jwt.verify(token, JWT_SECRET). Accepts token from
`Authorization: Bearer` header OR the `token` cookie (header first).
B) COOKIES
- Access token cookie name: **token** (httpOnly, sameSite=lax, secure in prod)
- Refresh token cookie name: **refreshToken** (opaque, DB-validated — NOT a JWT,
cannot be verified locally; only the API can refresh it).
- DOMAIN: the **web app** sets `token` with `Domain=${COOKIE_DOMAIN}`
(production COOKIE_DOMAIN = **.structumai.build**) so ALL subdomains and
sub-paths on that host receive it. (The API's own cookies are host-only and
not used for cross-app sharing — the web-set cookie is the shared one.)
- The cookie is **httpOnly** → your module can read it on the SERVER
(Next route handlers / proxy.ts), NOT in client-side JS.
C) ENDPOINTS (reachable at both /api/auth/... and /api/v1/auth/...)
- POST /api/v1/auth/login → body { token, refreshToken, user } + sets cookies
- GET /api/v1/auth/me → { user, tenant } (needs token)
- POST /api/v1/auth/refresh → rotates tokens (needs refreshToken)
- POST /api/v1/auth/logout → clears cookies
D) DATABASE
- PostgreSQL, env var **DATABASE_URL**. Multi-tenant: every row has tenant_id.
- user.id / tenant.id are UUID strings. The JWT's userId/tenantId ARE those
UUIDs. DB columns are snake_case (tenant_id), JWT claims are camelCase.
- Share the SAME DATABASE_URL (or the same DB) so your module's tenant_id /
user_id match what the token carries.
E) CORS (only relevant if your module's browser code calls the API directly
cross-origin — the Plans pattern does NOT, see Part 3):
- Allowlist + credentials:true in StructumAI-api src/server.ts.
- If your module calls the API from a new origin, ADD that origin to
`allowedOrigins` (or FRONTEND_URL) in the API, or calls will be blocked.
F) WEB (StructumAI-web) facts you need:
- Login page path: **/login**, and it honors a **?redirect=<path>** query to
send the user back after login (NOTE: param is `redirect`, see Part 3-G).
- Site gate: env **SITE_PASSWORD**; unlock route /api/site-unlock sets a
`site_unlock` cookie (1 hour). This gate is on the WEB app only — your
module doesn't implement it, but the user must pass it to reach your module
if it's served under the same host.
- COOKIE_DOMAIN must be set to **.structumai.build** in production for the
shared `token` cookie to reach your module.
================================================================
PART 3 — HOW TO CONNECT YOUR MODULE (the Plans pattern, recommended)
================================================================
Deploy your module the SAME way Plans is deployed:
G) DEPLOY AS A SUB-PATH ON THE PLATFORM HOST (simplest, cookie "just works")
- Plans runs at https://staging.structumai.build/plans (nginx reverse-proxy
to the module's Next server; the module sets NEXT_PUBLIC_BASE_PATH=/plans).
- Do the same: Estimate → /estimate, Schedule → /schedule, each with its own
NEXT_PUBLIC_BASE_PATH. Because it's the SAME host, the `token` cookie is
sent automatically — no cross-domain cookie config needed.
- (Alternative: a subdomain like estimate.structumai.build also works because
COOKIE_DOMAIN=.structumai.build shares the cookie — but sub-path is simpler
and matches Plans.)
LOGIN REDIRECT PARAM — IMPORTANT:
- The web login reads **?redirect=<path>**. Your module's auth gate (proxy.ts)
should redirect unauthenticated users to:
{NEXT_PUBLIC_APP_URL}/login?redirect=<the path they wanted>
Use the param name `redirect` (the Plans code currently uses `returnTo`;
for the web to send the user back correctly, use `redirect`). After login
the web returns the user to that path.
H) WHAT YOUR MODULE MUST DO
1. Read the `token` cookie (server-side) on every API route + in proxy.ts.
2. Verify it: jwt.verify(token, JWT_SECRET) — SAME JWT_SECRET as the API.
3. Read claims: userId, tenantId, email. Scope ALL queries by tenant_id.
4. On missing/expired/invalid token: return 401 (API routes) / redirect to
login (pages) — NEVER 500.
5. Share the same DATABASE_URL so tenant/user IDs line up.
I) ENV VARS YOUR MODULE NEEDS (put real values in BOTH .env.local AND
.env.production on the server — see Part 5 for the priority gotcha):
JWT_SECRET=<EXACT same value as StructumAI-api> # required, must match
DATABASE_URL=<same Postgres as the platform> # shared tenant/user IDs
NEXT_PUBLIC_BASE_PATH=/estimate (or /schedule) # baked at build time
NEXT_PUBLIC_APP_URL=https://staging.structumai.build # for login redirect
# AWS_* if the module stores files (region MUST match the bucket's region;
# bucket must be in the same AWS account as the key — see Part 6)
# Local dev only: ALLOW_DEV_AUTH=true (NEVER in production)
================================================================
PART 4 — THE CODE TO PUT IN YOUR MODULE (copy from Plans)
================================================================
These are the exact patterns from the working Plans module. The previous
developer's modules (Plans AND Schedule) had a broken version that returned
500 instead of 401 on a missing/expired token and assumed a rigid claim shape —
fix them with the code below.
----------------------------------------------------------------
4.1 lib/auth.ts — UnauthorizedError + getUserContext + apiErrorResponse
----------------------------------------------------------------
import { NextRequest, NextResponse } from 'next/server';
import jwt from 'jsonwebtoken';
export interface UserContext {
userId: string; userName: string; userEmail: string; tenantId: string;
}
export class UnauthorizedError extends Error { // → maps to HTTP 401
status = 401 as const;
constructor(message = 'Unauthorized') { super(message); this.name = 'UnauthorizedError'; }
}
export function isDevAuthAllowed(): boolean { // local dev escape hatch
return !process.env.JWT_SECRET || process.env.ALLOW_DEV_AUTH === 'true';
}
function firstClaim(p: Record<string, unknown>, keys: string[]) {
for (const k of keys) {
const v = p[k];
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v).trim();
}
return undefined;
}
// Canonical platform claims are userId / tenantId / email. The extra names
// are a safety net in case the platform token shape ever changes.
function mapClaimsToContext(p: Record<string, unknown>): UserContext {
const userId = firstClaim(p, ['userId','user_id','sub','id','uid']);
const tenantId = firstClaim(p, ['tenantId','tenant_id','tid','organizationId','org_id']);
const userEmail = firstClaim(p, ['email','userEmail','user_email']) || '';
const userName = firstClaim(p, ['userName','name','user_name','full_name']) || userEmail || userId || '';
if (!userId || !tenantId) throw new UnauthorizedError('Token missing user id / tenant id');
return { userId, userName, userEmail, tenantId };
}
function readToken(req: NextRequest): string | undefined {
const cookie = req.cookies.get('token')?.value; // platform cookie name
if (cookie) return cookie;
const auth = req.headers.get('authorization');
if (auth?.startsWith('Bearer ')) return auth.slice(7).trim() || undefined;
return undefined;
}
function devContext(req: NextRequest): UserContext {
const h = (k: string) => req.headers.get(k)?.trim() || undefined;
const userId = h('x-user-id') || process.env.DEV_USER_ID || 'user_001';
return {
userId,
userName: h('x-user-name') || userId || process.env.DEV_USER_NAME || 'Dev Admin',
userEmail: h('x-user-email') || process.env.DEV_USER_EMAIL || 'dev@structum.ai',
tenantId: h('x-tenant-id') || process.env.DEV_TENANT_ID || 'tenant_001',
};
}
export function getUserContext(req: NextRequest): UserContext {
const token = readToken(req);
if (process.env.JWT_SECRET && token) {
try {
return mapClaimsToContext(jwt.verify(token, process.env.JWT_SECRET) as Record<string, unknown>);
} catch (err) {
if (err instanceof UnauthorizedError) throw err;
if (isDevAuthAllowed()) return devContext(req);
throw new UnauthorizedError('Invalid or expired session token');
}
}
if (isDevAuthAllowed()) return devContext(req);
throw new UnauthorizedError('Missing session token');
}
// Use in EVERY route catch block: auth → 401, else → safe 500 (no leak).
export function apiErrorResponse(err: unknown, fallback = 'Internal server error', status = 500) {
if (err instanceof UnauthorizedError)
return NextResponse.json({ data: null, error: { message: err.message }, message: err.message }, { status: 401 });
return NextResponse.json({ data: null, error: { message: fallback }, message: fallback }, { status });
}
----------------------------------------------------------------
4.2 Every API route catch block
----------------------------------------------------------------
} catch (err) {
console.error('[<route>] Error:', err);
return apiErrorResponse(err, 'Failed to <do the thing>'); // 401 on auth, not 500
}
----------------------------------------------------------------
4.3 proxy.ts (Next.js 16 — file MUST be named proxy.ts, default export)
----------------------------------------------------------------
import { NextRequest, NextResponse } from 'next/server';
function decodeJwtExp(t: string): number | null {
try {
const p = t.split('.')[1]; if (!p) return null;
const b64 = p.replace(/-/g,'+').replace(/_/g,'/');
const pad = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
const { exp } = JSON.parse(atob(pad)) as { exp?: number };
return typeof exp === 'number' ? exp : null;
} catch { return null; }
}
function tokenLooksValid(t: string){ if(t.split('.').length!==3) return false; const e=decodeJwtExp(t); return e===null?true:e*1000>Date.now(); }
function isDevAuthAllowed(){ return !process.env.JWT_SECRET || process.env.ALLOW_DEV_AUTH === 'true'; }
function loginUrl(){ return process.env.STRUCTUM_LOGIN_URL
|| (process.env.NEXT_PUBLIC_APP_URL ? `${process.env.NEXT_PUBLIC_APP_URL.replace(/\/$/,'')}/login` : null); }
export default function proxy(req: NextRequest) {
const { pathname, search } = req.nextUrl;
if (pathname.startsWith('/api/v1/cron/')) return NextResponse.next(); // cron self-auth
if (pathname === '/api/v1/health') return NextResponse.next(); // public health
if (isDevAuthAllowed()) return NextResponse.next();
const token = req.cookies.get('token')?.value;
if (token && tokenLooksValid(token)) return NextResponse.next();
if (pathname.startsWith('/api/'))
return NextResponse.json({ data:null, error:{message:'Unauthorized'}, message:'Unauthorized' }, { status: 401 });
const url = loginUrl();
if (!url) return NextResponse.next();
const sep = url.includes('?') ? '&' : '?';
// IMPORTANT: the web login reads `redirect`, so use redirect= (not returnTo=)
const res = NextResponse.redirect(`${url}${sep}redirect=${encodeURIComponent(pathname + search)}`);
if (token) res.cookies.set('token', '', { maxAge: 0, path: '/' });
return res;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)'],
};
----------------------------------------------------------------
4.4 app/api/v1/health/route.ts (public — verify a deploy without logging in)
----------------------------------------------------------------
Returns { ok, db:'up'|'down', dbError, jwtConfigured, jwtIsPlaceholder, devAuth }.
Runs `SELECT 1` against the DB. Exempted in proxy.ts (see 4.3). This single
endpoint tells you instantly if the DB connects and the real JWT secret is set.
----------------------------------------------------------------
4.5 Client side (if your module has its own frontend)
----------------------------------------------------------------
- A fetch wrapper that prepends NEXT_PUBLIC_BASE_PATH and sends credentials
(cookies) on every /api call. NEVER use raw axios for /api URLs (it skips the
base path + cookies and breaks under the sub-path deploy).
- useCurrentUser(): GET /api/v1/me; on 401, redirect to
NEXT_PUBLIC_APP_URL + '/login?redirect=' + current path.
================================================================
PART 5 — DEPLOYMENT GOTCHAS (these cost the most time on Plans)
================================================================
5.1 ENV FILE PRIORITY (Next.js): .env.local > .env.production > .env
A value in .env.local OVERRIDES .env.production. Put the real JWT_SECRET
and DATABASE_URL in BOTH files (or at least .env.local). On Plans we lost
hours because the real secret was only in .env.production while .env.local
still had the placeholder.
5.2 JWT_SECRET MUST EQUAL the StructumAI-api secret (the token issuer).
It is NOT in the web repo — it's in the API service's environment. Get that
exact value. The placeholder `your-super-secret-...` is NOT real; with it,
every login fails verification.
5.3 ALLOW_DEV_AUTH=true is LOCAL ONLY. Never set it in production (it bypasses
auth and uses a dev identity).
5.4 NEXT_PUBLIC_BASE_PATH is baked in at BUILD time → set it before
`pnpm run build` / `npm run build`, not just at restart.
5.5 DATABASE: a Postgres `28P01 auth_failed` in logs = wrong DB user/password.
Test the connection without logging in via /api/v1/health.
(Local Neon/serverless DBs sleep — give the pg Pool a 15s
connectionTimeoutMillis so the first query after wake doesn't 500.)
5.6 COOKIE_DOMAIN: the WEB app must set COOKIE_DOMAIN=.structumai.build in
production, or the `token` cookie won't reach a module on a different
subdomain. (Sub-path modules on the same host get it regardless.)
5.7 CRON routes authenticate with CRON_SECRET (Bearer) — exempt them in
proxy.ts (see 4.3), don't gate them with the user token.
================================================================
PART 6 — S3 / FILE STORAGE (only if the module stores files)
================================================================
- AWS_REGION must be the bucket's REAL region, or you get
"PermanentRedirect: must be addressed using the specified endpoint".
- The bucket must be in the SAME AWS ACCOUNT as the access key, or you get
"NoSuchBucket". (On Plans, `structum-staging` was in a different account
than the key, so we used the bucket the key actually owns + its real region.)
- The bucket's CORS must allow the site origin (https://staging.structumai.build).
================================================================
PART 7 — VERIFICATION CHECKLIST (do all)
================================================================
[ ] /api/v1/health → db:"up", jwtIsPlaceholder:false, devAuth:false (in prod).
[ ] Not-logged-in API call returns 401 (NOT 500):
curl -s -o /dev/null -w "%{http_code}" https://<host>/<basePath>/api/v1/me → 401
[ ] Log into the platform (staging.structumai.build), then open the module path
(e.g. /estimate) → data loads, no re-login.
[ ] In the browser console ON the platform domain:
fetch('/<basePath>/api/v1/me').then(r=>r.json()).then(console.log)
→ returns real userId + tenantId (proves cookie + JWT verify + claims).
[ ] Tenant scoping: the module only shows data for the token's tenantId.
[ ] No 28P01 in logs (DB connects). File upload/download works (S3 ok).
[ ] proxy.ts redirects unauthenticated PAGE requests to
{NEXT_PUBLIC_APP_URL}/login?redirect=... and they come back after login.
IF /me RETURNS 401 EVEN AFTER LOGIN:
- Confirm the cookie NAME is `token` (DevTools → Application → Cookies).
- Decode the cookie's middle section (base64url) and confirm claims are
userId / tenantId / email. If a name differs, add it to the lists in 4.1.
- Confirm JWT_SECRET exactly matches the StructumAI-api secret.
- Confirm COOKIE_DOMAIN=.structumai.build so the cookie reaches your host.
================================================================
PART 8 — TO LINK THE MODULE IN THE PLATFORM UI
================================================================
The platform's left-nav registry is in StructumAI-web:
src/components/layout/AppSidebar.tsx → the `navigation` array.
Add an item there pointing to your module's path (e.g. /estimate, /schedule,
/plans). For sub-path modules on the same host, a normal link works and the
session cookie carries over. Pass context (projectId) via query string the
same way Plans does: /plans?projectId=<uuid>.
================================================================
REFERENCE — working Plans files to copy from
================================================================
lib/auth.ts (4.1)
proxy.ts (4.3)
app/api/v1/health/route.ts (4.4)
app/api/v1/notifications/route.ts (example of 4.2 catch → apiErrorResponse)
lib/dev-fetch.ts, lib/hooks/useCurrentUser.ts (4.5 client side)
AUTH_AND_FIXES.md (narrative of the original Plans auth fix)
PLATFORM SOURCES (read-only reference):
StructumAI-api/src/services/auth.service.ts (signJwt, verifyToken)
StructumAI-api/src/controllers/auth.controller.ts (sets `token` cookie)
StructumAI-api/src/middleware/auth.middleware.ts (verify: header or cookie)
StructumAI-api/src/config/env.ts (JWT_SECRET, DATABASE_URL)
StructumAI-web/src/app/api/set-token/route.js (web sets shared cookie + COOKIE_DOMAIN)
StructumAI-web/src/proxy.ts (web auth gate, ?redirect=)
StructumAI-web/src/app/api/site-unlock/route.js (SITE_PASSWORD gate)
================================================================