83ec5ecd36
Every route had to remember to add the business filter to every query;
nothing stopped one that forgot. The audit for this change found several
that had: the M-Pesa status re-check, the stock ledger's duplicate checks,
the daily-inventory and branch-price upserts, a cash-sale replay lookup,
several load-by-id lookups, and every populate().
- tenantGuard: a Mongoose plugin on all ten business-scoped models. Any
find/update/delete/count/distinct, or an aggregate whose first stage is
not a $match, that does not pin exactly one businessId throws
TenantScopeError before reaching the database ($in, $exists and other
operators do not count).
- crossTenant("reason", fn): the explicit, documented exemption for the
queries that must span businesses - login's email lookup, refresh and
invite-token lookups, the M-Pesa callback, the platform console and the
maintenance scripts. git grep crossTenant lists them all.
- scopedPopulate: populated branch, product and staff names are also
restricted to the business.
- Every flagged query site now names the business (or is exempted with a
reason).
- Tests: a check that every model with a businessId field carries the
guard; the guard refusing unscoped queries of every kind; and a route
sweep where business A sends business B's ids to every read route
(nothing of B's comes back) and every write route (all refused, B's
data unchanged). Test-side database inspections go through db(), an
explicit test exemption. Removing one route's business filter makes the
suite fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
115 lines
5.1 KiB
TypeScript
115 lines
5.1 KiB
TypeScript
import dotenv from "dotenv";
|
|
import path from "path";
|
|
// Plain `dotenv` only auto-loads a file literally named `.env` — it does
|
|
// NOT know about Next.js's `.env.local` convention. This script runs
|
|
// standalone via tsx (outside Next.js's own env loading), so it has to
|
|
// point dotenv at `.env.local` explicitly, or every var here silently
|
|
// stays undefined even when the file exists and is filled in correctly.
|
|
dotenv.config({ path: path.resolve(__dirname, "../.env.local") });
|
|
|
|
import bcrypt from "bcryptjs";
|
|
import mongoose from "mongoose";
|
|
import { connectDb } from "../src/lib/server/db";
|
|
import { User } from "../src/lib/server/models/User";
|
|
import { Product } from "../src/lib/server/models/Product";
|
|
import { Business } from "../src/lib/server/models/Business";
|
|
import { isPlatformAdminEmail } from "../src/lib/server/platform";
|
|
import { crossTenant } from "../src/lib/server/tenantGuard";
|
|
|
|
/**
|
|
* Run once against a fresh database: `npm run seed`.
|
|
* Creates the first SUPER_ADMIN (email/password, from env) so there's a
|
|
* way into the single /login page at all, and seeds a default jerrycan
|
|
* size catalog. Safe to re-run — it's idempotent (skips if already
|
|
* present).
|
|
*/
|
|
async function seed() {
|
|
await connectDb();
|
|
|
|
const email = process.env.SEED_SUPER_ADMIN_EMAIL;
|
|
const password = process.env.SEED_SUPER_ADMIN_PASSWORD;
|
|
|
|
// Everything the demo seed creates belongs to one demo Business tenant.
|
|
// In the live product, businesses are created via owner self-signup at
|
|
// /signup; this seed just mirrors that shape for a fresh local DB.
|
|
const demoBusinessName = process.env.SEED_BUSINESS_NAME || "Demo Water & Eggs Co.";
|
|
|
|
if (!email || !password) {
|
|
console.error("SEED_SUPER_ADMIN_EMAIL / SEED_SUPER_ADMIN_PASSWORD not set — nothing to seed for auth.");
|
|
await mongoose.disconnect();
|
|
process.exit(0);
|
|
}
|
|
|
|
let business = await Business.findOne({ name: demoBusinessName });
|
|
// Emails are unique per business, so look in the seeded business (or, for
|
|
// a legacy single-tenant database, for its one super admin).
|
|
const existing = business
|
|
? await User.findOne({ email, businessId: business._id })
|
|
: await User.findOne({ email, role: "SUPER_ADMIN" });
|
|
// The seed is the only way (besides the identity migration) to make an
|
|
// operator: the flag is honoured only while the email is also listed in
|
|
// PLATFORM_ADMIN_EMAILS.
|
|
const platformAdmin = isPlatformAdminEmail(email);
|
|
|
|
if (existing) {
|
|
console.log(`Super admin (${email}) already exists, skipping`);
|
|
if (platformAdmin && !existing.platformAdmin) {
|
|
existing.platformAdmin = true;
|
|
await existing.save();
|
|
console.log(`Marked ${email} as a platform operator`);
|
|
}
|
|
if (!business) {
|
|
// Backfill a business for a legacy single-tenant admin if needed.
|
|
business = await Business.create({ name: demoBusinessName, ownerId: existing._id });
|
|
existing.businessId = business._id;
|
|
await existing.save();
|
|
console.log(`Linked existing super admin to new business "${demoBusinessName}"`);
|
|
}
|
|
} else {
|
|
const passwordHash = await bcrypt.hash(password, 12);
|
|
// Create the owner first with a placeholder business, then the
|
|
// business, then point them at each other — mirrors signup ordering.
|
|
const owner = new User({
|
|
name: "Super Admin",
|
|
role: "SUPER_ADMIN",
|
|
branchId: null,
|
|
phoneNumber: "254700000000",
|
|
email,
|
|
passwordHash,
|
|
platformAdmin,
|
|
businessId: new mongoose.Types.ObjectId(), // temporary, overwritten below
|
|
});
|
|
business = await Business.create({ name: demoBusinessName, ownerId: owner._id });
|
|
owner.businessId = business._id;
|
|
await owner.save();
|
|
console.log(`Business "${demoBusinessName}" + super admin created (${email}) — change the password after first login`);
|
|
}
|
|
|
|
const businessId = business!._id;
|
|
|
|
const existingProducts = await Product.countDocuments({ businessId });
|
|
if (existingProducts === 0) {
|
|
await Product.insertMany([
|
|
// Water catalog
|
|
{ businessId, label: "5L Jerrycan", sizeLiters: 5, unit: "L", category: "Water", unitPrice: 60, sortOrder: 1 },
|
|
{ businessId, label: "10L Jerrycan", sizeLiters: 10, unit: "L", category: "Water", unitPrice: 110, sortOrder: 2 },
|
|
{ businessId, label: "20L Jerrycan", sizeLiters: 20, unit: "L", category: "Water", unitPrice: 200, sortOrder: 3 },
|
|
// Eggs catalog
|
|
{ businessId, label: "Tray of Eggs (30)", sizeLiters: 30, unit: "pcs", category: "Eggs", unitPrice: 400, sortOrder: 4 },
|
|
{ businessId, label: "Half Tray (15)", sizeLiters: 15, unit: "pcs", category: "Eggs", unitPrice: 210, sortOrder: 5 },
|
|
{ businessId, label: "Single Egg", sizeLiters: 1, unit: "pcs", category: "Eggs", unitPrice: 15, sortOrder: 6 },
|
|
]);
|
|
console.log("Seeded default product catalog (water 5L/10L/20L + eggs tray/half/single)");
|
|
} else {
|
|
console.log("Products already exist for this business, skipping catalog seed");
|
|
}
|
|
|
|
await mongoose.disconnect();
|
|
process.exit(0);
|
|
}
|
|
|
|
crossTenant("seeding may find a legacy admin outside any business", seed).catch((err) => {
|
|
console.error("seed failed:", err.message);
|
|
process.exit(1);
|
|
});
|