AZ
Back to constellation
CommercePublished

Managerenta

A multi-tenant property/rental management SaaS — and the Next.js Model→Service→Route reference architecture (with a real IAM engine) the other commerce apps clone.

Next.js 16TypeScriptMongoose 9Redis (ioredis)AWS S3 + sharpAWS-IAM-style policy engineVitestPlaywright
Read the write-up

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:

LayerLives inOwnsNever touches
Modelmodels/<res>/{index,types}.tsMongoose schema + flat xxxDB() data-access fns; schema hooks; per-call Prometheus timersHTTP, caching, business rules
Serviceservices/<res>/<verb>.ts (one fn per file)Orchestration: models + S3 + Redis cache + notificationsreq/Response, Mongoose internals
Routeapp/api/<res>/route.tsauthorize → Zod safeParse → call service → response envelopeDB 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:

#StepWhy in this position
1CSRF Origin/Referer gatecheapest check, no Redis; a reject must not burn a rate-limit token
2Rate limit (100 req / 60s, Redis)opt-out per route via rateLimit: false
3connectMongoDB()readiness before any handler DB call
4handlerthe actual route body
5observe() Prometheuswrapped 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.

Related write-ups