@finesoft/front 0.1.76 → 0.1.77
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/01-getting-started.md +230 -0
- package/docs/02-routing-and-controllers.md +197 -0
- package/docs/03-middleware.md +214 -0
- package/docs/04-rendering-and-hydration.md +271 -0
- package/docs/05-i18n.md +243 -0
- package/docs/06-http-client.md +286 -0
- package/docs/07-di-container.md +264 -0
- package/docs/08-observability.md +290 -0
- package/docs/09-server-and-deployment.md +242 -0
- package/docs/10-features-platform-pwa.md +238 -0
- package/docs/README.md +72 -0
- package/docs/advanced/custom-action-handler.md +248 -0
- package/docs/advanced/custom-adapter.md +264 -0
- package/docs/advanced/custom-event-recorder.md +318 -0
- package/docs/advanced/inline-proxy-codegen.md +200 -0
- package/docs/advanced/multi-tenant-scopes.md +330 -0
- package/docs/engineering/ci-release-flow.md +244 -0
- package/docs/engineering/project-structure.md +296 -0
- package/docs/engineering/testing.md +317 -0
- package/docs/pitfalls/container-scope-leak.md +215 -0
- package/docs/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/docs/zh/01-getting-started.md +230 -0
- package/docs/zh/02-routing-and-controllers.md +197 -0
- package/docs/zh/03-middleware.md +214 -0
- package/docs/zh/04-rendering-and-hydration.md +271 -0
- package/docs/zh/05-i18n.md +243 -0
- package/docs/zh/06-http-client.md +286 -0
- package/docs/zh/07-di-container.md +264 -0
- package/docs/zh/08-observability.md +287 -0
- package/docs/zh/09-server-and-deployment.md +242 -0
- package/docs/zh/10-features-platform-pwa.md +238 -0
- package/docs/zh/README.md +72 -0
- package/docs/zh/advanced/custom-action-handler.md +248 -0
- package/docs/zh/advanced/custom-adapter.md +264 -0
- package/docs/zh/advanced/custom-event-recorder.md +318 -0
- package/docs/zh/advanced/inline-proxy-codegen.md +200 -0
- package/docs/zh/advanced/multi-tenant-scopes.md +330 -0
- package/docs/zh/engineering/ci-release-flow.md +244 -0
- package/docs/zh/engineering/project-structure.md +296 -0
- package/docs/zh/engineering/testing.md +317 -0
- package/docs/zh/pitfalls/container-scope-leak.md +215 -0
- package/docs/zh/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/zh/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/zh/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/zh/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/zh/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/package.json +2 -1
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Advanced: inline proxy codegen
|
|
2
|
+
|
|
3
|
+
For serverless and edge deployments where you want proxy logic inlined into the function bundle — no runtime call to `registerProxyRoutes`, no extra dependencies — the framework exposes `generateProxyCode`.
|
|
4
|
+
|
|
5
|
+
## Use case
|
|
6
|
+
|
|
7
|
+
You're deploying to Cloudflare Workers / Vercel Edge / AWS Lambda@Edge. Each function has:
|
|
8
|
+
|
|
9
|
+
- Tight cold-start budget
|
|
10
|
+
- Cold-bundle-size budget (Workers: 1 MB compressed)
|
|
11
|
+
- No `process.env` in some runtimes
|
|
12
|
+
|
|
13
|
+
Importing the proxy router and its support files (validators, Hono integration) adds bytes. `generateProxyCode` emits **only** the lines you need for the routes you declared. The output is self-contained: a few `app.get(...)` / `app.all(...)` calls plus a single `_sanitizeProxyPath` helper.
|
|
14
|
+
|
|
15
|
+
## Generated output
|
|
16
|
+
|
|
17
|
+
Given this input:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { generateProxyCode } from "@finesoft/front";
|
|
21
|
+
|
|
22
|
+
const code = generateProxyCode([
|
|
23
|
+
{
|
|
24
|
+
prefix: "/api",
|
|
25
|
+
target: "https://upstream.example",
|
|
26
|
+
headers: { "X-App": "myapp" },
|
|
27
|
+
auth: { type: "bearer", envKey: "API_TOKEN" },
|
|
28
|
+
cache: "max-age=60",
|
|
29
|
+
},
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
console.log(code);
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
You get something like:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
// ─── 框架声明式代理路由 ───
|
|
39
|
+
function _sanitizeProxyPath(raw) {
|
|
40
|
+
if (raw.length > 2048) return null;
|
|
41
|
+
try {
|
|
42
|
+
if (decodeURIComponent(raw) !== raw) return null;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
if (raw.startsWith("//")) return null;
|
|
47
|
+
if (!/^[/\w.\-~%:@!$&'()*+,;=]*$/.test(raw)) return null;
|
|
48
|
+
return raw.startsWith("/") ? raw : "/" + raw;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
app.all("/api/*", async (c) => {
|
|
52
|
+
const _sub = _sanitizeProxyPath(c.req.path.replace("/api", ""));
|
|
53
|
+
if (!_sub) return c.text("Invalid path", 400);
|
|
54
|
+
const _target = new URL(_sub, "https://upstream.example");
|
|
55
|
+
if (_target.origin !== "https://upstream.example") return c.text("Invalid proxy target", 400);
|
|
56
|
+
const _reqUrl = new URL(c.req.url);
|
|
57
|
+
_reqUrl.searchParams.forEach((v, k) => _target.searchParams.set(k, v));
|
|
58
|
+
const _headers = { "X-App": "myapp" };
|
|
59
|
+
const _token =
|
|
60
|
+
(typeof process !== "undefined" && process.env && process.env["API_TOKEN"]) || "";
|
|
61
|
+
if (_token) _headers.Authorization = "Bearer " + _token;
|
|
62
|
+
try {
|
|
63
|
+
const _resp = await fetch(_target.toString(), { headers: _headers, redirect: "manual" });
|
|
64
|
+
const _cl = _resp.headers.get("Content-Length");
|
|
65
|
+
if (_cl && parseInt(_cl, 10) > 10485760) {
|
|
66
|
+
return c.text("Proxy response too large", 502);
|
|
67
|
+
}
|
|
68
|
+
const _body = await _resp.arrayBuffer();
|
|
69
|
+
if (_body.byteLength > 10485760) {
|
|
70
|
+
return c.text("Proxy response too large", 502);
|
|
71
|
+
}
|
|
72
|
+
const _rh = { "Content-Type": _resp.headers.get("Content-Type") || "application/json" };
|
|
73
|
+
if ("max-age=60") _rh["Cache-Control"] = "max-age=60";
|
|
74
|
+
return c.newResponse(_body, _resp.status, _rh);
|
|
75
|
+
} catch (_e) {
|
|
76
|
+
console.error("[Proxy /api]", _e);
|
|
77
|
+
return c.json({ error: "Proxy request failed" }, 502);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Everything is inline. No imports from `@finesoft/front` for the proxy path. Drop this into your function bundle alongside the SSR entry.
|
|
83
|
+
|
|
84
|
+
## When to use codegen vs runtime registration
|
|
85
|
+
|
|
86
|
+
| Concern | Runtime (`registerProxyRoutes`) | Codegen (`generateProxyCode`) |
|
|
87
|
+
| ----------------------------------------- | ------------------------------- | ------------------------------------------- |
|
|
88
|
+
| Long-lived server (Node, Workers) | ✅ preferred | ✅ also fine |
|
|
89
|
+
| Tiny edge functions (Lambda@Edge) | Heavier import | ✅ minimal |
|
|
90
|
+
| Need to update routes without redeploying | ✅ change config, restart | ❌ redeploy required |
|
|
91
|
+
| Config from a remote service | ✅ supported | ❌ codegen runs at build time |
|
|
92
|
+
| Multiple proxies sharing helpers | ✅ shared at runtime | Code duplication unless you dedupe yourself |
|
|
93
|
+
|
|
94
|
+
Use codegen specifically when bundle size matters. For most deployments, the runtime path is fine.
|
|
95
|
+
|
|
96
|
+
## Build-time integration
|
|
97
|
+
|
|
98
|
+
A typical setup:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
// scripts/build-proxy.mjs
|
|
102
|
+
import { generateProxyCode } from "@finesoft/front";
|
|
103
|
+
import { writeFile } from "node:fs/promises";
|
|
104
|
+
|
|
105
|
+
const code = generateProxyCode([
|
|
106
|
+
{ prefix: "/api/users", target: "https://users.internal" },
|
|
107
|
+
{ prefix: "/api/products", target: "https://products.internal", cache: "max-age=30" },
|
|
108
|
+
{
|
|
109
|
+
prefix: "/api/orders",
|
|
110
|
+
target: "https://orders.internal",
|
|
111
|
+
auth: { type: "bearer", envKey: "ORDERS_TOKEN" },
|
|
112
|
+
},
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
const wrapper = `
|
|
116
|
+
import { Hono } from "hono";
|
|
117
|
+
const app = new Hono();
|
|
118
|
+
|
|
119
|
+
${code}
|
|
120
|
+
|
|
121
|
+
export default app;
|
|
122
|
+
`;
|
|
123
|
+
|
|
124
|
+
await writeFile("dist/proxy.js", wrapper, "utf8");
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Then import `./proxy.js` from your serverless function entry:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
// dist/main.ts (for Cloudflare Worker)
|
|
131
|
+
import proxyApp from "./proxy.js";
|
|
132
|
+
import ssrApp from "./ssr-bundle.js";
|
|
133
|
+
|
|
134
|
+
const app = new Hono();
|
|
135
|
+
app.route("/", proxyApp);
|
|
136
|
+
app.route("/", ssrApp);
|
|
137
|
+
|
|
138
|
+
export default app;
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## What the generated code does for you
|
|
142
|
+
|
|
143
|
+
The generated handler enforces the same guarantees as the runtime path:
|
|
144
|
+
|
|
145
|
+
- **SSRF protection**: path validation rejects encoded chars, `//` prefix, non-allowed characters
|
|
146
|
+
- **Open-redirect protection**: `target.origin` must match the configured target's origin
|
|
147
|
+
- **10 MB response size limit**: `Content-Length` fast-reject + `byteLength` actual-bytes check
|
|
148
|
+
- **Binary integrity**: `arrayBuffer()` forwarding (no UTF-8 decoding)
|
|
149
|
+
- **Auth from env**: reads `process.env[envKey]` at request time
|
|
150
|
+
|
|
151
|
+
The framework's test suite asserts **parity** between runtime and generated code with these checks:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
// packages/server/test/proxy.test.ts
|
|
155
|
+
test("generated proxy code embeds the same response size limit as runtime (parity)", () => {
|
|
156
|
+
const code = generateProxyCode([{ prefix: "/api", target: "https://upstream.example" }]);
|
|
157
|
+
|
|
158
|
+
const MAX = String(10 * 1024 * 1024);
|
|
159
|
+
expect(code).toContain(`parseInt(_cl, 10) > ${MAX}`);
|
|
160
|
+
expect(code).toContain(`_body.byteLength > ${MAX}`);
|
|
161
|
+
});
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
If you patch the runtime path's size limit, the generated code's limit is updated in lockstep.
|
|
165
|
+
|
|
166
|
+
## Caveats
|
|
167
|
+
|
|
168
|
+
### `process.env` may not exist
|
|
169
|
+
|
|
170
|
+
The generated code guards with `typeof process !== "undefined"`. On runtimes without `process` (some edge environments), the auth header is simply not added — the upstream sees no auth.
|
|
171
|
+
|
|
172
|
+
For platforms like Cloudflare Workers that inject env via function args instead of `process.env`, you'll need to either:
|
|
173
|
+
|
|
174
|
+
- Wrap the generated code to inject the auth header from the worker's env arg
|
|
175
|
+
- Replace the auth section after generation with the platform-appropriate access
|
|
176
|
+
|
|
177
|
+
### No retries, no breakers
|
|
178
|
+
|
|
179
|
+
The generated handler does one `fetch` and bubbles failures up as `502 Proxy request failed`. For retry / breaker logic, write your own proxy code — `generateProxyCode` is intentionally minimal.
|
|
180
|
+
|
|
181
|
+
### Multiple proxies share helper code
|
|
182
|
+
|
|
183
|
+
`_sanitizeProxyPath` is emitted once at the top of the generated string. Multiple `app.all` calls share it. If you `generateProxyCode` separately for each route and concatenate, you'll get the helper repeated — call it once with all routes.
|
|
184
|
+
|
|
185
|
+
### Validation at generation time
|
|
186
|
+
|
|
187
|
+
`generateProxyCode` runs the same `validateConfig` as `registerProxyRoutes`. Invalid configs throw at build time:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
generateProxyCode([{ prefix: "/api", target: "file:///etc/passwd" }]);
|
|
191
|
+
// Error: [proxy] target must start with "https://" or "http://": "file:///etc/passwd"
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
This catches config errors before the deploy ships.
|
|
195
|
+
|
|
196
|
+
## Related
|
|
197
|
+
|
|
198
|
+
- [Chapter 9: Server & deployment — proxy routes](../09-server-and-deployment.md#proxy-routes)
|
|
199
|
+
- The implementation: `packages/server/src/proxy.ts`
|
|
200
|
+
- The parity test: `packages/server/test/proxy.test.ts`
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# Advanced: multi-tenant scopes
|
|
2
|
+
|
|
3
|
+
A multi-tenant app serves multiple customers from one deployment, but each customer's request must see their own:
|
|
4
|
+
|
|
5
|
+
- Database connection / API client
|
|
6
|
+
- Logger / metrics tagged with the tenant
|
|
7
|
+
- Feature flags / pricing / branding
|
|
8
|
+
- Cached translations / content
|
|
9
|
+
|
|
10
|
+
The DI container's child scopes are the right primitive. This recipe shows how to wire per-tenant isolation through a `beforeLoad` guard.
|
|
11
|
+
|
|
12
|
+
## Mental model
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
Framework
|
|
16
|
+
└── parent Container ← shared services (HTTP pool, base recorder, ...)
|
|
17
|
+
└── per-request scope ← framework creates this for every SSR request
|
|
18
|
+
↑
|
|
19
|
+
└── tenant overrides registered by a beforeLoad guard
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The per-request scope is created by the framework automatically. Your guard registers tenant-specific overrides on top. Anything you don't override falls through to the parent.
|
|
23
|
+
|
|
24
|
+
## Step 1: identify the tenant
|
|
25
|
+
|
|
26
|
+
This is your business logic. Common sources:
|
|
27
|
+
|
|
28
|
+
- **Subdomain**: `acme.myapp.com` → `acme`
|
|
29
|
+
- **Path prefix**: `/t/acme/...` → `acme`
|
|
30
|
+
- **Header**: `X-Tenant-Id: acme`
|
|
31
|
+
- **Authenticated user**: cookie → session → tenant
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
// src/lib/tenants/resolve.ts
|
|
35
|
+
import type { NavigationContext } from "@finesoft/front";
|
|
36
|
+
|
|
37
|
+
export function resolveTenant(ctx: NavigationContext): string | null {
|
|
38
|
+
const host = ctx.url.hostname;
|
|
39
|
+
const sub = host.split(".")[0];
|
|
40
|
+
if (sub && sub !== "www" && sub !== "myapp") return sub;
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Step 2: load tenant config
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
// src/lib/tenants/config.ts
|
|
49
|
+
export interface TenantConfig {
|
|
50
|
+
tenantId: string;
|
|
51
|
+
displayName: string;
|
|
52
|
+
upstreamUrl: string;
|
|
53
|
+
apiToken: string;
|
|
54
|
+
featureFlags: Record<string, unknown>;
|
|
55
|
+
locale: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const cache = new Map<string, TenantConfig>();
|
|
59
|
+
|
|
60
|
+
export async function getTenantConfig(tenantId: string): Promise<TenantConfig | null> {
|
|
61
|
+
if (cache.has(tenantId)) return cache.get(tenantId)!;
|
|
62
|
+
|
|
63
|
+
// Load from your config store — file, DB, KV
|
|
64
|
+
const config = await loadFromStore(tenantId);
|
|
65
|
+
if (!config) return null;
|
|
66
|
+
|
|
67
|
+
cache.set(tenantId, config);
|
|
68
|
+
return config;
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
A real implementation would have cache invalidation on config updates. For most apps, refresh on a timer or via a webhook is enough.
|
|
73
|
+
|
|
74
|
+
## Step 3: register tenant services in a guard
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
// src/lib/guards/tenant.ts
|
|
78
|
+
import { deny, next, type NavigationContext, DEP_KEYS } from "@finesoft/front";
|
|
79
|
+
import { resolveTenant } from "../tenants/resolve";
|
|
80
|
+
import { getTenantConfig } from "../tenants/config";
|
|
81
|
+
import { UserApi } from "../api/user";
|
|
82
|
+
import { WithFieldsRecorder } from "@finesoft/front";
|
|
83
|
+
|
|
84
|
+
export async function tenantGuard(ctx: NavigationContext) {
|
|
85
|
+
const tenantId = resolveTenant(ctx);
|
|
86
|
+
if (!tenantId) return deny(404, "Unknown tenant");
|
|
87
|
+
|
|
88
|
+
const config = await getTenantConfig(tenantId);
|
|
89
|
+
if (!config) return deny(404, "Tenant not found");
|
|
90
|
+
|
|
91
|
+
// Register tenant-specific services on the request scope
|
|
92
|
+
const scope = ctx.container;
|
|
93
|
+
scope.register("tenantConfig", () => config);
|
|
94
|
+
|
|
95
|
+
scope.register(
|
|
96
|
+
"userApi",
|
|
97
|
+
() =>
|
|
98
|
+
new UserApi({
|
|
99
|
+
baseUrl: config.upstreamUrl,
|
|
100
|
+
defaultHeaders: { Authorization: `Bearer ${config.apiToken}` },
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
scope.register(DEP_KEYS.FEATURE_FLAGS, () => ({
|
|
105
|
+
get: (key, fallback) => config.featureFlags[key] ?? fallback,
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
// Decorate the parent's recorder with tenant context
|
|
109
|
+
const baseRecorder = scope.parent!.resolve(DEP_KEYS.EVENT_RECORDER);
|
|
110
|
+
scope.register(
|
|
111
|
+
DEP_KEYS.EVENT_RECORDER,
|
|
112
|
+
() => new WithFieldsRecorder(baseRecorder, [{ getFields: () => ({ tenantId }) }]),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return next();
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Key insights:
|
|
120
|
+
|
|
121
|
+
- **The scope is already created by the framework.** You register on `ctx.container` — that's the request scope.
|
|
122
|
+
- **Fall-through is automatic.** Anything not registered here resolves from the parent container.
|
|
123
|
+
- **Decorate, don't replace.** The recorder is wrapped with tenant fields rather than replaced — base behavior (HTTP transmission, batching) stays in place.
|
|
124
|
+
|
|
125
|
+
## Step 4: install the guard globally
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
// src/bootstrap.ts
|
|
129
|
+
import { type Framework, defineRoutes } from "@finesoft/front";
|
|
130
|
+
import { tenantGuard } from "./lib/guards/tenant";
|
|
131
|
+
// ... other imports
|
|
132
|
+
|
|
133
|
+
export function bootstrap(framework: Framework): void {
|
|
134
|
+
framework.middleware.use("beforeLoad", tenantGuard);
|
|
135
|
+
|
|
136
|
+
defineRoutes(framework, [
|
|
137
|
+
{ path: "/", intentId: "home", controller: new HomeController() },
|
|
138
|
+
{ path: "/billing", intentId: "billing", controller: new BillingController() },
|
|
139
|
+
// ...
|
|
140
|
+
]);
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`tenantGuard` runs before any route-specific guard, so by the time any controller executes, the tenant scope is set up.
|
|
145
|
+
|
|
146
|
+
## Step 5: controllers transparently get the right services
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
// src/controllers/billing.ts
|
|
150
|
+
export class BillingController extends BaseController<{}, BillingPage> {
|
|
151
|
+
readonly intentId = "billing";
|
|
152
|
+
|
|
153
|
+
async execute(_params, container) {
|
|
154
|
+
const config = container.resolve<TenantConfig>("tenantConfig");
|
|
155
|
+
const api = container.resolve<UserApi>("userApi"); // tenant-specific client
|
|
156
|
+
|
|
157
|
+
const usage = await api.getUsage();
|
|
158
|
+
const invoices = await api.getInvoices();
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
kind: "billing",
|
|
162
|
+
tenantName: config.displayName,
|
|
163
|
+
usage,
|
|
164
|
+
invoices,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The controller doesn't know about tenants — it just resolves `userApi` and gets the right one for this request.
|
|
171
|
+
|
|
172
|
+
## Cross-tenant prohibitions
|
|
173
|
+
|
|
174
|
+
To prevent a user authenticated for tenant A from accessing tenant B's data:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
async function sameTenantGuard(ctx: NavigationContext) {
|
|
178
|
+
const session = await ctx.container.resolve<SessionService>("session").current();
|
|
179
|
+
const requestedTenant = ctx.container.resolve<TenantConfig>("tenantConfig").tenantId;
|
|
180
|
+
|
|
181
|
+
if (!session) return redirect("/login");
|
|
182
|
+
if (session.tenantId !== requestedTenant) return deny(403, "Cross-tenant access forbidden");
|
|
183
|
+
|
|
184
|
+
return next();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Apply after tenantGuard:
|
|
188
|
+
framework.middleware.use("beforeLoad", tenantGuard);
|
|
189
|
+
framework.middleware.use("beforeLoad", sameTenantGuard);
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Guard order matters — `tenantGuard` must register `tenantConfig` before `sameTenantGuard` reads it.
|
|
193
|
+
|
|
194
|
+
## Hydration considerations
|
|
195
|
+
|
|
196
|
+
Tenant configs include `featureFlags`, which are read on both server and client. The framework's `PrefetchedIntents` serialization handles this — the browser receives the same flag values the server saw, so client-side reads stay consistent.
|
|
197
|
+
|
|
198
|
+
Tenant config itself is **not** automatically serialized. If your view needs to display `tenantConfig.displayName`, the controller should include it in the `Page` object:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
async execute(_params, container) {
|
|
202
|
+
const config = container.resolve<TenantConfig>("tenantConfig");
|
|
203
|
+
return {
|
|
204
|
+
kind: "home",
|
|
205
|
+
tenant: {
|
|
206
|
+
id: config.tenantId,
|
|
207
|
+
displayName: config.displayName,
|
|
208
|
+
},
|
|
209
|
+
// ...
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
The `Page` is serialized, so it survives the SSR → CSR boundary. The full `TenantConfig` (with secrets) should never end up in a `Page`.
|
|
215
|
+
|
|
216
|
+
## Browser-side considerations
|
|
217
|
+
|
|
218
|
+
`tenantGuard` runs on the browser too — on initial navigation and on every subsequent navigation. For browser-only apps (`renderMode: "csr"`), this is the only time it runs.
|
|
219
|
+
|
|
220
|
+
But the browser can't safely resolve secrets like `apiToken`. Two approaches:
|
|
221
|
+
|
|
222
|
+
**Approach 1: server proxies all API calls.** The browser hits `/api/users` (your proxy), which forwards to `${upstreamUrl}/users` with the auth header injected from `process.env[apiTokenEnvKey]`. The browser never sees the token.
|
|
223
|
+
|
|
224
|
+
**Approach 2: short-lived session token.** The server issues a JWT scoped to the tenant; the browser uses it for direct upstream calls. Token rotation handled by your auth layer.
|
|
225
|
+
|
|
226
|
+
Most apps go with approach 1. The framework's proxy router is designed for it.
|
|
227
|
+
|
|
228
|
+
## Caveats
|
|
229
|
+
|
|
230
|
+
### Don't cache tenant scopes across requests
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
// BAD
|
|
234
|
+
const tenantScopeCache = new Map<string, Container>();
|
|
235
|
+
|
|
236
|
+
async function tenantGuard(ctx) {
|
|
237
|
+
let scope = tenantScopeCache.get(tenantId);
|
|
238
|
+
if (!scope) {
|
|
239
|
+
scope = framework.container.createScope();
|
|
240
|
+
tenantScopeCache.set(tenantId, scope);
|
|
241
|
+
}
|
|
242
|
+
// Use scope...
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Each request needs its own scope — even for the same tenant — because:
|
|
247
|
+
|
|
248
|
+
- Other guards add request-specific overrides (auth, trace id) that shouldn't leak across requests
|
|
249
|
+
- The scope holds resolved instances of stateful services; sharing them across requests breaks isolation
|
|
250
|
+
|
|
251
|
+
Tenant **config** can be cached. Tenant **scope** cannot.
|
|
252
|
+
|
|
253
|
+
### Be careful with shared service instances
|
|
254
|
+
|
|
255
|
+
If you cache the `UserApi` instance at module level instead of registering a factory, all requests share state:
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
// BAD
|
|
259
|
+
const apiByTenant = new Map<string, UserApi>();
|
|
260
|
+
scope.register("userApi", () => {
|
|
261
|
+
let api = apiByTenant.get(tenantId);
|
|
262
|
+
if (!api) {
|
|
263
|
+
api = new UserApi({ baseUrl: config.upstreamUrl });
|
|
264
|
+
apiByTenant.set(tenantId, api);
|
|
265
|
+
}
|
|
266
|
+
return api;
|
|
267
|
+
});
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
If `UserApi` has any request-scoped state (interceptors that capture closures over request-specific values), they'll bleed across tenants. Register a fresh factory; let the container cache it per scope.
|
|
271
|
+
|
|
272
|
+
## Testing
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
import { describe, test, expect, vi, afterEach } from "vite-plus/test";
|
|
276
|
+
import { Container } from "@finesoft/front";
|
|
277
|
+
import { tenantGuard } from "./tenant";
|
|
278
|
+
|
|
279
|
+
afterEach(() => vi.restoreAllMocks());
|
|
280
|
+
|
|
281
|
+
describe("tenantGuard", () => {
|
|
282
|
+
test("registers tenant services for known tenant", async () => {
|
|
283
|
+
const parent = new Container();
|
|
284
|
+
const scope = parent.createScope();
|
|
285
|
+
|
|
286
|
+
vi.spyOn(await import("../tenants/config"), "getTenantConfig").mockResolvedValue({
|
|
287
|
+
tenantId: "acme",
|
|
288
|
+
displayName: "Acme Co",
|
|
289
|
+
upstreamUrl: "https://acme.example",
|
|
290
|
+
apiToken: "token-123",
|
|
291
|
+
featureFlags: { darkMode: true },
|
|
292
|
+
locale: "en-US",
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const ctx = {
|
|
296
|
+
url: new URL("https://acme.myapp.com/"),
|
|
297
|
+
container: scope,
|
|
298
|
+
intent: { intentId: "home", params: {} },
|
|
299
|
+
getCookie: () => null,
|
|
300
|
+
getHeader: () => null,
|
|
301
|
+
isSsr: true,
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const result = await tenantGuard(ctx as any);
|
|
305
|
+
|
|
306
|
+
expect(result).toEqual({ kind: "next" });
|
|
307
|
+
expect(scope.resolve("tenantConfig")).toMatchObject({ tenantId: "acme" });
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("denies unknown tenant", async () => {
|
|
311
|
+
const ctx = {
|
|
312
|
+
url: new URL("https://unknown.myapp.com/"),
|
|
313
|
+
container: new Container(),
|
|
314
|
+
intent: { intentId: "home", params: {} },
|
|
315
|
+
getCookie: () => null,
|
|
316
|
+
getHeader: () => null,
|
|
317
|
+
isSsr: true,
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const result = await tenantGuard(ctx as any);
|
|
321
|
+
expect(result).toMatchObject({ kind: "deny", status: 404 });
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
## Related
|
|
327
|
+
|
|
328
|
+
- [Chapter 7: DI container](../07-di-container.md) — scopes and fallback resolution
|
|
329
|
+
- [Chapter 3: Middleware](../03-middleware.md) — global guards
|
|
330
|
+
- [Pitfall: container scope leak](../pitfalls/container-scope-leak.md) — what goes wrong if you cache scopes
|