Overview
What it is
Managerenta is a multi-tenant property/rental management SaaS: a Next.js 16 App-Router monolith with a strict, server-only backend, Mongoose 9 on MongoDB, Redis for cache + rate-limit, S3 for images, and a hand-rolled AWS-IAM-style authorization engine. But its more durable output is a reference architecture — the Model → Service → Route triad that the owner's other commerce apps (Chekka, Mogadget, Golden Bite) are cloned from. Get the skeleton right once, and every new product spends its novelty budget on the actual problem instead of re-litigating how a request becomes a database write.
The Model → Service → Route triad
All server code lives under src/server/ and imports "server-only", so DB
handles, S3 clients and secrets can never be pulled into the client bundle.
Three layers, each with exactly one job and no reach past its own boundary:
| Layer | Lives in | Owns | Never touches |
|---|---|---|---|
| Model | models/<res>/{index,types}.ts | Mongoose schema + flat xxxDB() data-access fns; schema hooks; per-call Prometheus timers | HTTP, caching, business rules |
| Service | services/<res>/<verb>.ts (one fn per file) | Orchestration: models + S3 + Redis cache + notifications | req/Response, Mongoose internals |
| Route | app/api/<res>/route.ts | authorize → Zod safeParse → call service → response envelope | DB queries, cache keys, S3 |
Model — persistence plus the cross-cutting invariants, pushed into schema
hooks so every read path inherits them for free. In models/properties/index.ts
a pre("aggregate") injects the soft-delete filter and normalizes _id → id,
and a post("aggregate") fans stored S3 keys out to signed URLs with
Promise.allSettled so one bad key can't fail the whole batch:
schema.pre("aggregate", function () {
this.pipeline().unshift({ $match: { deleted: false } });
this.pipeline().push({ $addFields: { id: { $toString: "$_id" } } });
this.pipeline().push({ $project: { __v: 0, deleted: 0 } });
});
schema.post("aggregate", async (documents: IProperty[]) => {
await Promise.allSettled(documents.map(async (doc) => {
if (doc.image) doc.image = (await s3GetFileLink({ fileName: doc.image })) ?? doc.image;
}));
});
Every *DB function wraps a databaseResponseTimeHistogram timer labelled
{ operation, collection, method, success } and returns null/empty on a read
error rather than throwing — the service decides what an empty result means.
Ownership is a query-level invariant, not a post-hoc check: updates and deletes
filter { _id, userId, deleted: false }, "delete" is a soft-delete $set, and
writes use returnDocument: "after" (never the deprecated new: true).
Service — one function per file, orchestrating models + S3 + Redis + notifications.
getProperties computes a namespaced Redis key, returns a cache hit, or fans a
Promise.all of the DB reads and caches the result under a 5-minute TTL:
const query = getQueryKey({ userId, limit, offset, search, type, sort });
if (!refreshCache) {
const cached = await redisRetrieveKeyString<GetPropertiesResult>(query);
if (cached) return cached;
}
const [{ properties, total }, stats, unitStats] = await Promise.all([
getPropertiesDB({ userId, ... }), getPropertyStatsDB({ userId }), getUnitStatsDB({ userId }),
]);
await redisUpdateKeyString(query, result, true, 5 * 60);
Route — thin by construction: authorize → validate → call service → shape the
envelope, all inside the withApiHandler(withAuth(...)) wrapper.
export const POST = withApiHandler({ route: "/api/properties" }, withAuth(async ({ req, auth }) => {
try {
await authorize(auth, "properties:Create", arn.org.properties(resourceScope(auth)), { req });
const parsed = await parseMultipart(req as NextRequest);
const body = createPropertyBodySchema.safeParse(parsed.fields);
if (!body.success) throw ErrInvalidFields;
const result = await createProperty({ payload: { ...body.data, image, userId: auth.effectiveOwnerId } });
return created(result, "Property created successfully");
} catch (error) { return handleError(error); }
}));
The wrapper enforces order
withApiHandler composes the cross-cutting concerns in a deliberate, load-bearing
sequence. The CSRF Origin/Referer gate runs before the rate limiter — it is
the cheaper check (no Redis call), and a failed check must not spend a
rate-limit token — then Mongo readiness, the handler, and Prometheus timing:
| # | Step | Why in this position |
|---|---|---|
| 1 | CSRF Origin/Referer gate | cheapest check, no Redis; a reject must not burn a rate-limit token |
| 2 | Rate limit (100 req / 60s, Redis) | opt-out per route via rateLimit: false |
| 3 | connectMongoDB() | readiness before any handler DB call |
| 4 | handler | the actual route body |
| 5 | observe() Prometheus | wrapped so metrics never break a request |
if (options.csrf !== false) {
const reason = csrfReject(req);
if (reason) { const res = fail(403, reason); observe(req, res.status, options.route, startNs); return res; }
}
if (rl) {
rlResult = await enforceRateLimit(req, rl);
if (!rlResult.allowed) return applyRateLimitHeaders(fail(429, "Too many requests..."), rlResult);
}
await connectMongoDB();
Access control: deny-wins, not roles
Two moving parts. withAuth resolves the JWT (with silent refresh-token
rotation and auto cookie reset) into an AuthResult whose load-bearing field is
effectiveOwnerId: the user's own id in personal scope, or the org owner's
id once they've switched into an organization — so org members transparently
operate on the owner's resources through a single evaluation path, and the org
owner is implicitly ADMIN.
Authorization itself is a real AWS-IAM-style policy engine (iam/engine.ts), not
inline role checks. evaluate() is pure — no I/O, no clock read (time arrives
via context.currentTime) — so it is deterministic and exhaustively
unit-testable, and it follows AWS semantics exactly: default-deny,
explicit-Deny-wins, allow only on an explicit matching Allow:
for (const policy of policies) {
for (const statement of policy.document.statements) {
if (!statementMatches(statement, action, resource, context)) continue;
if (statement.effect === "Deny") return { decision: "deny", reason: `explicit deny by ...` };
if (statement.effect === "Allow" && !allow) allow = { statement, policyName: policy.name };
}
}
return allow ? { decision: "allow", ... } : { decision: "deny", reason: "implicit deny (no matching allow)" };
Above the policy loop sits a hard tenant-isolation floor in authorize.ts,
checked before any statement is considered — so even a wildcard customer
policy can never reach another tenant's resources:
if (target.plane === "org" && target.orgId !== resourceScope(auth)) {
return { decision: "deny", reason: "implicit deny (cross-scope org resource)" };
}
Personal-scope users are handed an in-memory selfScopePolicy (Allow * on
resources under their own id) so there is one evaluation path for everyone —
no hardcoded decision branch in the engine. Platform-plane actions require an
active operator identity resolved from the DB; a disabled operator is denied
before any policy lookup. Denials are audited with the engine's reason, which is
never leaked to the client. This engine replaced ad-hoc role checks after the
audit found org-admins could remove the owner.
Testing & security posture
93 Vitest files under tests/ mirror the src/server tree; a globalSetup
drops every scratch DB the run created (isolated throwaway databases), and
coverage deliberately excludes runtime/ and cron.ts as "coverage theater."
16 Playwright specs drive auth, 2FA, passkeys, properties, tenants,
organizations, the admin console, the portal, notifications, settings, and a
full-app drive.
The receipts are a real three-pass SECURITY_REVIEW.md (~40 findings): Pass 1
infra/dependency (C/H/M/LOW), Pass 2 an auth-gate audit (the S-series), Pass 3 a
backlog cleanup (__Host- cookies, CSRF Origin/Referer, tokens hashed at rest,
an updateUserRawDB allowlist). The standout is S1 — "2FA was never enforced
on login; the toggle was decorative" — found by audit and fixed with a two-step
ticket flow (a 5-minute signed ticket, then POST /api/auth/login/2fa). Others:
a regex-injection in a user lookup, SVG/stored-XSS with magic-byte MIME sniffing,
and an unauthenticated /api/metrics in production.
Deploy — one repo, two pipelines
A multi-stage Dockerfile (node:20-alpine deps → builder → runner, Next
standalone output, non-root nextjs:nodejs uid/gid 1001) that buildspec.yml
pushes to ECR via AWS CodeBuild (tagged with the 7-char commit hash, emitting
imagedefinitions.json); or an AWS Amplify build (amplify.yml). Same tree, two
targets.