feat(platform): product ownership + owner-scoped "my projects" + tenant guard

Foundation for a generic, multi-tenant platform (any developer, not just the
built-in products).

- Products carry an optional ownerId (set on create + auto-register), so a
  product has a tenant. GET /products/mine returns the caller's owner-scoped
  list; admins/super_admins see all. productsForUser() is pure + unit-tested.
- requireProductAccess(): a flag-gated tenant authorization guard
  (FLEET_TENANT_ENFORCEMENT, default OFF). OFF = byte-for-byte current behavior;
  ON = a non-admin may only act on products they own (others -> 403; owner-less
  legacy products keep a grace allowance until migrated). Fleet routes now
  resolve productId through it in place of getRequestProductId.

ownerId is additive/optional; enforcement is off by default, so this is a
no-op for existing deployments until explicitly enabled.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
saravanakumardb1 2026-06-01 16:47:05 -07:00
parent 2c4357b71b
commit 42c63dcc6e
12 changed files with 200 additions and 38 deletions

View File

@ -105,6 +105,7 @@ export async function autoRegisterProduct(
deviceLimits: { free: 1, pro: 3, enterprise: 10 },
websiteUrl: '',
status: 'active',
ownerId: userId,
createdAt: now,
updatedAt: now,
};

View File

@ -0,0 +1,54 @@
/**
* requireProductAccess flag-gated, owner-scoped tenant authorization (§tenancy).
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import type { FastifyRequest } from 'fastify';
let authPayload: { sub: string; role?: string } = { sub: 'owner_1', role: 'user' };
vi.mock('./auth.js', () => ({ extractAuth: vi.fn(async () => authPayload) }));
vi.mock('./auto-register.js', () => ({ autoRegisterProduct: vi.fn() }));
vi.mock('../modules/products/cache.js', () => ({
isValidProduct: () => true,
getProduct: (id: string) => ({ id, productId: id, status: 'active', ownerId: 'owner_1' }),
}));
import { requireProductAccess, isTenantEnforcementEnabled } from './request-context.js';
import { ForbiddenError } from './errors.js';
const reqFor = (productId: string): FastifyRequest =>
({ jwtPayload: { sub: authPayload.sub, productId }, headers: {} }) as unknown as FastifyRequest;
afterEach(() => {
delete process.env.FLEET_TENANT_ENFORCEMENT;
authPayload = { sub: 'owner_1', role: 'user' };
});
describe('requireProductAccess', () => {
it('enforcement OFF (default): resolves without an access check', async () => {
authPayload = { sub: 'someone_else', role: 'user' };
await expect(requireProductAccess(reqFor('p1'))).resolves.toBe('p1');
expect(isTenantEnforcementEnabled()).toBe(false);
});
it('enforcement ON: the owner is allowed', async () => {
process.env.FLEET_TENANT_ENFORCEMENT = '1';
authPayload = { sub: 'owner_1', role: 'user' };
await expect(requireProductAccess(reqFor('p1'))).resolves.toBe('p1');
});
it('enforcement ON: a non-owner, non-admin user is rejected (403)', async () => {
process.env.FLEET_TENANT_ENFORCEMENT = '1';
authPayload = { sub: 'intruder', role: 'user' };
await expect(requireProductAccess(reqFor('p1'))).rejects.toBeInstanceOf(ForbiddenError);
});
it('enforcement ON: admins and super_admins are always allowed', async () => {
process.env.FLEET_TENANT_ENFORCEMENT = '1';
authPayload = { sub: 'intruder', role: 'admin' };
await expect(requireProductAccess(reqFor('p1'))).resolves.toBe('p1');
authPayload = { sub: 'intruder', role: 'super_admin' };
await expect(requireProductAccess(reqFor('p1'))).resolves.toBe('p1');
});
});

View File

@ -10,10 +10,11 @@
*/
import type { FastifyRequest } from 'fastify';
import { BadRequestError } from './errors.js';
import { BadRequestError, ForbiddenError } from './errors.js';
import { isValidProduct, getProduct } from '../modules/products/cache.js';
import type { ProductDoc } from '../modules/products/types.js';
import { autoRegisterProduct } from './auto-register.js';
import { extractAuth } from './auth.js';
/** JWT payload shape attached to req by the onRequest hook (see Commit 3). */
export interface JwtPayload {
@ -136,3 +137,42 @@ export function getRequestProductConfig(req: FastifyRequest): ProductDoc {
const id = getRequestProductId(req);
return getProduct(id)!;
}
/**
* `FLEET_TENANT_ENFORCEMENT` env gate default OFF. When OFF, tenant scoping is
* advisory only (the dashboard shows you your projects, but the API does not
* reject cross-tenant access) byte-for-byte the current behavior. When ON, the
* API enforces that a caller may only act on products they own (admins exempt).
*/
export function isTenantEnforcementEnabled(): boolean {
const v = (process.env.FLEET_TENANT_ENFORCEMENT ?? '').trim().toLowerCase();
return v === '1' || v === 'true' || v === 'on' || v === 'yes';
}
/**
* Resolve the request's productId AND authorize the caller for it (multi-tenant
* guard, §tenancy). Resolution + status gating is unchanged (`getRequestProductId`);
* the access check is additive and flag-gated:
*
* - enforcement OFF returns the resolved id (no behavior change).
* - admins / super_admins always allowed (operators see everything).
* - otherwise allowed only when the product is owned by the caller, OR is
* owner-less/legacy (grace for products created before ownership tracking;
* migrate them to lock down fully). A product owned by SOMEONE ELSE 403.
*
* Async because it needs the verified auth identity. Use this on tenant-scoped
* surfaces (e.g. the fleet control plane) in place of `getRequestProductId`.
*/
export async function requireProductAccess(req: FastifyRequest): Promise<string> {
const id = getRequestProductId(req);
if (!isTenantEnforcementEnabled()) return id;
const auth = await extractAuth(req);
if (auth.role === 'admin' || auth.role === 'super_admin') return id;
const product = getProduct(id);
if (product?.ownerId && product.ownerId !== auth.sub) {
throw new ForbiddenError(`Not authorized for product '${id}'`);
}
return id;
}

View File

@ -25,6 +25,7 @@ vi.mock('../../lib/auth.js', () => ({
}));
vi.mock('../../lib/request-context.js', () => ({
getRequestProductId: () => 'lysnrai',
requireProductAccess: async () => 'lysnrai',
}));
const PID = 'lysnrai';

View File

@ -21,6 +21,7 @@ vi.mock('../../lib/auth.js', () => ({
}));
vi.mock('../../lib/request-context.js', () => ({
getRequestProductId: () => 'lysnrai',
requireProductAccess: async () => 'lysnrai',
}));
const PID = 'lysnrai';

View File

@ -13,6 +13,7 @@ vi.mock('../../lib/auth.js', () => ({
}));
vi.mock('../../lib/request-context.js', () => ({
getRequestProductId: () => 'lysnrai',
requireProductAccess: async () => 'lysnrai',
}));
async function buildApp(): Promise<FastifyInstance> {

View File

@ -25,7 +25,7 @@
*/
import type { FastifyInstance } from 'fastify';
import { getRequestProductId } from '../../lib/request-context.js';
import { requireProductAccess } from '../../lib/request-context.js';
import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from '../../lib/errors.js';
import { extractAuth } from '../../lib/auth.js';
import * as repo from './repository.js';
@ -64,8 +64,8 @@ function badRequest(issues: { message: string }[]): never {
* matches it. Prevents a caller authenticated for product A from reading or
* mutating another product's fleet budget. Returns the caller's productId.
*/
function requireOwnProduct(req: import('fastify').FastifyRequest): string {
const pid = getRequestProductId(req);
async function requireOwnProduct(req: import('fastify').FastifyRequest): Promise<string> {
const pid = await requireProductAccess(req);
const { productId } = req.params as { productId?: string };
if (productId && productId !== pid) {
throw new ForbiddenError("Cannot access another product's fleet budget");
@ -88,7 +88,7 @@ export async function fleetRoutes(app: FastifyInstance) {
await extractAuth(req);
const parsed = SubmitJobSchema.safeParse(req.body);
if (!parsed.success) badRequest(parsed.error.issues);
const pid = parsed.data.productId || getRequestProductId(req);
const pid = parsed.data.productId || (await requireProductAccess(req));
const result = await coordinator.submitJob(pid, parsed.data);
reply.code(result.outcome === 'created' ? 201 : 200);
return { outcome: result.outcome, job: result.job };
@ -100,7 +100,7 @@ export async function fleetRoutes(app: FastifyInstance) {
const parsed = ListJobsQuerySchema.safeParse(req.query);
if (!parsed.success) badRequest(parsed.error.issues);
const q = parsed.data;
const pid = q.productId || getRequestProductId(req);
const pid = q.productId || (await requireProductAccess(req));
const jobs = await repo.listJobs({
productId: pid,
stage: q.stage,
@ -115,7 +115,7 @@ export async function fleetRoutes(app: FastifyInstance) {
app.get('/fleet/jobs/:id', async req => {
await extractAuth(req);
const { id } = req.params as { id: string };
const pid = getRequestProductId(req);
const pid = await requireProductAccess(req);
const job = await repo.getJob(id, pid);
if (!job) throw new NotFoundError('Job not found');
return job;
@ -125,7 +125,7 @@ export async function fleetRoutes(app: FastifyInstance) {
app.patch('/fleet/jobs/:id', async req => {
await extractAuth(req);
const { id } = req.params as { id: string };
const pid = getRequestProductId(req);
const pid = await requireProductAccess(req);
const parsed = PatchJobSchema.safeParse(req.body);
if (!parsed.success) badRequest(parsed.error.issues);
const res = await coordinator.patchJobFenced(id, pid, parsed.data);
@ -148,7 +148,7 @@ export async function fleetRoutes(app: FastifyInstance) {
app.post('/fleet/jobs/:id/actions/:action', async req => {
await extractAuth(req);
const { id, action } = req.params as { id: string; action: string };
const pid = getRequestProductId(req);
const pid = await requireProductAccess(req);
if (!(coordinator.OPERATOR_ACTIONS as readonly string[]).includes(action)) {
throw new BadRequestError(
`unknown operator action '${action}' (expected: ${coordinator.OPERATOR_ACTIONS.join(', ')})`
@ -170,7 +170,7 @@ export async function fleetRoutes(app: FastifyInstance) {
app.patch('/fleet/jobs/:id/draft', async req => {
await extractAuth(req);
const { id } = req.params as { id: string };
const pid = getRequestProductId(req);
const pid = await requireProductAccess(req);
const parsed = UpdateDraftSchema.safeParse(req.body);
if (!parsed.success) badRequest(parsed.error.issues);
const res = await coordinator.updateDraft(id, pid, parsed.data);
@ -185,7 +185,7 @@ export async function fleetRoutes(app: FastifyInstance) {
app.post('/fleet/jobs/:id/submit', async req => {
await extractAuth(req);
const { id } = req.params as { id: string };
const pid = getRequestProductId(req);
const pid = await requireProductAccess(req);
const res = await coordinator.submitDraft(id, pid);
if (!res.ok) {
if (res.reason === 'not_found') throw new NotFoundError('Job not found');
@ -199,7 +199,7 @@ export async function fleetRoutes(app: FastifyInstance) {
app.post('/fleet/jobs/:id/review/request', async req => {
await extractAuth(req);
const { id } = req.params as { id: string };
const pid = getRequestProductId(req);
const pid = await requireProductAccess(req);
const body = (req.body ?? {}) as { requiredApprovals?: number; reviewers?: string[] };
const res = await coordinator.requestReview(id, pid, {
requiredApprovals: body.requiredApprovals,
@ -222,7 +222,7 @@ export async function fleetRoutes(app: FastifyInstance) {
app.post('/fleet/jobs/:id/review', async req => {
await extractAuth(req);
const { id } = req.params as { id: string };
const pid = getRequestProductId(req);
const pid = await requireProductAccess(req);
const parsed = SubmitReviewSchema.safeParse(req.body);
if (!parsed.success) badRequest(parsed.error.issues);
const res = await coordinator.submitReview(id, pid, parsed.data);
@ -244,7 +244,7 @@ export async function fleetRoutes(app: FastifyInstance) {
await extractAuth(req);
const parsed = ClaimSchema.safeParse(req.body);
if (!parsed.success) badRequest(parsed.error.issues);
const pid = parsed.data.productId || getRequestProductId(req);
const pid = parsed.data.productId || (await requireProductAccess(req));
// §12: when enforcement is ON, the token must cover the productId + requested
// capabilities + factoryId; the claim is then CONSTRAINED to the verified scope
// (a factory cannot claim outside it). When OFF, behaves exactly as before.
@ -267,7 +267,7 @@ export async function fleetRoutes(app: FastifyInstance) {
app.post('/fleet/jobs/:id/lease/renew', async req => {
await extractAuth(req);
const { id } = req.params as { id: string };
const pid = getRequestProductId(req);
const pid = await requireProductAccess(req);
const parsed = RenewLeaseSchema.safeParse(req.body);
if (!parsed.success) badRequest(parsed.error.issues);
const res = await coordinator.renewLease(
@ -288,7 +288,7 @@ export async function fleetRoutes(app: FastifyInstance) {
app.post('/fleet/jobs/:id/lease/release', async req => {
await extractAuth(req);
const { id } = req.params as { id: string };
const pid = getRequestProductId(req);
const pid = await requireProductAccess(req);
const parsed = ReleaseLeaseSchema.safeParse(req.body);
if (!parsed.success) badRequest(parsed.error.issues);
const res = await coordinator.releaseLease(id, pid, parsed.data.leaseEpoch, parsed.data.stage, {
@ -313,7 +313,7 @@ export async function fleetRoutes(app: FastifyInstance) {
await extractAuth(req);
const parsed = HeartbeatSchema.safeParse(req.body);
if (!parsed.success) badRequest(parsed.error.issues);
const pidCandidate = parsed.data.productId || getRequestProductId(req);
const pidCandidate = parsed.data.productId || (await requireProductAccess(req));
// §12: gated token check (default OFF). When ON the token must cover this
// product + factory; the heartbeat is then bound to the verified scope.
const scope = await enrollment.enforceFactoryToken(req, {
@ -345,7 +345,7 @@ export async function fleetRoutes(app: FastifyInstance) {
// ── List factories for the product (powers the fleet map + engine picker) ──
app.get('/fleet/factories', async req => {
await extractAuth(req);
const pid = getRequestProductId(req);
const pid = await requireProductAccess(req);
const factories = await repo.listFactories(pid);
return { factories };
});
@ -437,7 +437,7 @@ export async function fleetRoutes(app: FastifyInstance) {
app.get('/fleet/jobs/:id/explain', async req => {
await extractAuth(req);
const { id } = req.params as { id: string };
const pid = getRequestProductId(req);
const pid = await requireProductAccess(req);
const explain = await coordinator.explainJob(id, pid);
if (!explain) throw new NotFoundError('Job not found');
return explain;
@ -446,7 +446,7 @@ export async function fleetRoutes(app: FastifyInstance) {
// ── Fleet metrics + alerting (§17) ──
app.get('/fleet/metrics', async req => {
await extractAuth(req);
const pid = getRequestProductId(req);
const pid = await requireProductAccess(req);
const metrics = await coordinator.fleetMetrics(pid);
// Attach process-wide recovery/GC telemetry (the reaper runs across products,
// so these counters are global — operator visibility into recovery activity).
@ -461,7 +461,7 @@ export async function fleetRoutes(app: FastifyInstance) {
// docs/GIGAFACTORY/FLEET_DISPATCH_REDESIGN.md §8/§12.
app.get('/fleet/queue-state', async req => {
await extractAuth(req);
const pid = getRequestProductId(req);
const pid = await requireProductAccess(req);
return { productId: pid, version: await repo.getQueueVersion(pid) };
});
@ -473,7 +473,7 @@ export async function fleetRoutes(app: FastifyInstance) {
if (!parsed.success) badRequest(parsed.error.issues);
// Review fix: the request/auth productId is authoritative — a spoofed
// body.productId must NOT override it (prevents writing into another product).
const pid = getRequestProductId(req);
const pid = await requireProductAccess(req);
const bytes = Buffer.from(parsed.data.contentBase64, 'base64');
if (bytes.length === 0) badRequest([{ message: 'contentBase64 decoded to empty bytes' }]);
const { artifact, downloadUrl } = await artifactsBlob.uploadArtifact({
@ -494,7 +494,7 @@ export async function fleetRoutes(app: FastifyInstance) {
const { id: jobId } = req.params as { id: string };
// Review fix: scope to the request productId so a caller only sees its own
// product's artifact pointers for this job.
const pid = getRequestProductId(req);
const pid = await requireProductAccess(req);
const artifacts = await repo.listArtifactsByJob(jobId, pid);
return { artifacts };
});
@ -503,7 +503,7 @@ export async function fleetRoutes(app: FastifyInstance) {
app.get('/fleet/artifacts/:artifactId', async req => {
await extractAuth(req);
const { artifactId } = req.params as { artifactId: string };
const pid = getRequestProductId(req);
const pid = await requireProductAccess(req);
const found = await artifactsBlob.getArtifactDownload(artifactId, pid);
if (!found) throw new NotFoundError('Artifact not found');
return found;
@ -513,7 +513,7 @@ export async function fleetRoutes(app: FastifyInstance) {
app.delete('/fleet/artifacts/:artifactId', async req => {
await extractAuth(req);
const { artifactId } = req.params as { artifactId: string };
const pid = getRequestProductId(req);
const pid = await requireProductAccess(req);
const deleted = await artifactsBlob.deleteArtifact(artifactId, pid);
if (!deleted) throw new NotFoundError('Artifact not found');
return { deleted: true };
@ -526,7 +526,7 @@ export async function fleetRoutes(app: FastifyInstance) {
await extractAuth(req);
const parsed = EnrollFactorySchema.safeParse(req.body);
if (!parsed.success) badRequest(parsed.error.issues);
const pid = parsed.data.productId || getRequestProductId(req);
const pid = parsed.data.productId || (await requireProductAccess(req));
const result = await enrollment.enrollFactory({
productId: pid,
factoryId: parsed.data.factoryId,
@ -548,7 +548,7 @@ export async function fleetRoutes(app: FastifyInstance) {
const { id: factoryId } = req.params as { id: string };
const parsed = RotateTokenSchema.safeParse(req.body ?? {});
if (!parsed.success) badRequest(parsed.error.issues);
const pid = parsed.data.productId || getRequestProductId(req);
const pid = parsed.data.productId || (await requireProductAccess(req));
const factory = await repo.getFactory(factoryId, pid);
if (!factory) throw new NotFoundError('Factory not found');
const issued = await enrollment.rotateToken({
@ -565,7 +565,7 @@ export async function fleetRoutes(app: FastifyInstance) {
const { id: factoryId } = req.params as { id: string };
const parsed = RevokeTokenSchema.safeParse(req.body ?? {});
if (!parsed.success) badRequest(parsed.error.issues);
const pid = parsed.data.productId || getRequestProductId(req);
const pid = parsed.data.productId || (await requireProductAccess(req));
const revoked = await enrollment.revokeToken({
productId: pid,
factoryId,
@ -577,7 +577,7 @@ export async function fleetRoutes(app: FastifyInstance) {
// ── Phase 3 Budgets: GET/PUT per-product budget ──
app.get('/fleet/budgets/:productId', async req => {
await extractAuth(req);
const productId = requireOwnProduct(req);
const productId = await requireOwnProduct(req);
const budget = await coordinator.getBudget(productId);
if (!budget) throw new NotFoundError('No budget configured for this product');
return budget;
@ -586,7 +586,7 @@ export async function fleetRoutes(app: FastifyInstance) {
// ── Cost burndown — spend-over-time vs ceiling (§14) ──
app.get('/fleet/budgets/:productId/burndown', async req => {
await extractAuth(req);
const productId = requireOwnProduct(req);
const productId = await requireOwnProduct(req);
const { days } = req.query as { days?: string };
const parsedDays = days ? Number.parseInt(days, 10) : undefined;
return coordinator.costBurndown(
@ -597,7 +597,7 @@ export async function fleetRoutes(app: FastifyInstance) {
app.put('/fleet/budgets/:productId', async (req, reply) => {
await extractAuth(req);
const productId = requireOwnProduct(req);
const productId = await requireOwnProduct(req);
const parsed = UpsertBudgetSchema.safeParse(req.body);
if (!parsed.success) badRequest(parsed.error.issues);
const budget = await coordinator.upsertBudget(
@ -612,7 +612,7 @@ export async function fleetRoutes(app: FastifyInstance) {
app.post('/fleet/budgets/:productId/pause', async req => {
await extractAuth(req);
const productId = requireOwnProduct(req);
const productId = await requireOwnProduct(req);
const budget = await coordinator.pauseBudget(productId);
if (!budget) throw new NotFoundError('No budget configured for this product');
return budget;
@ -620,7 +620,7 @@ export async function fleetRoutes(app: FastifyInstance) {
app.post('/fleet/budgets/:productId/resume', async req => {
await extractAuth(req);
const productId = requireOwnProduct(req);
const productId = await requireOwnProduct(req);
const budget = await coordinator.resumeBudget(productId);
if (!budget) throw new NotFoundError('No budget configured for this product');
return budget;
@ -630,7 +630,7 @@ export async function fleetRoutes(app: FastifyInstance) {
app.post('/fleet/jobs/:id/children', async (req, reply) => {
await extractAuth(req);
const { id: parentId } = req.params as { id: string };
const pid = getRequestProductId(req);
const pid = await requireProductAccess(req);
const parsed = SubmitChildrenSchema.safeParse(req.body);
if (!parsed.success) badRequest(parsed.error.issues);
const parent = await repo.getJob(parentId, pid);
@ -644,7 +644,7 @@ export async function fleetRoutes(app: FastifyInstance) {
app.get('/fleet/jobs/:id/dag', async req => {
await extractAuth(req);
const { id } = req.params as { id: string };
const pid = getRequestProductId(req);
const pid = await requireProductAccess(req);
const dag = await coordinator.getDagSubtree(id, pid);
if (!dag) throw new NotFoundError('Job not found');
return { dag };
@ -655,7 +655,7 @@ export async function fleetRoutes(app: FastifyInstance) {
await extractAuth(req);
const parsed = IngestItemSchema.safeParse(req.body);
if (!parsed.success) badRequest(parsed.error.issues);
const pid = getRequestProductId(req);
const pid = await requireProductAccess(req);
const result = await trackerBridge.ingestItemAsJob(pid, parsed.data.itemId, {
priority: parsed.data.priority,
});
@ -668,7 +668,7 @@ export async function fleetRoutes(app: FastifyInstance) {
await extractAuth(req);
const parsed = EchoJobSchema.safeParse(req.body);
if (!parsed.success) badRequest(parsed.error.issues);
const pid = getRequestProductId(req);
const pid = await requireProductAccess(req);
return trackerBridge.echoJobToItem(pid, parsed.data.jobId, req.log);
});
}

View File

@ -22,6 +22,7 @@ vi.mock('../../lib/auth.js', () => ({
}));
vi.mock('../../lib/request-context.js', () => ({
getRequestProductId: () => 'lysnrai',
requireProductAccess: async () => 'lysnrai',
}));
const PID = 'lysnrai';

View File

@ -14,6 +14,7 @@ import {
getProduct,
isValidProduct,
getAllProducts,
productsForUser,
cacheSize,
} from './cache.js';
@ -119,6 +120,37 @@ describe('product cache', () => {
});
});
describe('productsForUser (owner-scoped "my projects")', () => {
const owned = [
{ ...products[0], ownerId: 'user_a' },
{ ...products[1], ownerId: 'user_b' },
];
it('a normal user sees only the products they own', async () => {
mockGetAll.mockResolvedValue(owned);
await loadProductCache();
const mine = productsForUser('user_a', false);
expect(mine.map(p => p.id)).toEqual(['lysnrai']);
});
it('owner-less / other-owner products are hidden from a normal user', async () => {
mockGetAll.mockResolvedValue(owned);
await loadProductCache();
// invttrdg (the fallback) has no ownerId ⇒ never shown to a non-admin.
expect(productsForUser('user_a', false).map(p => p.id)).not.toContain('invttrdg');
expect(productsForUser('nobody', false)).toEqual([]);
});
it('an admin sees every product regardless of owner', async () => {
mockGetAll.mockResolvedValue(owned);
await loadProductCache();
const all = productsForUser('anyone', true);
expect(all.map(p => p.id)).toEqual(
expect.arrayContaining(['lysnrai', 'mindlyst', 'invttrdg'])
);
});
});
describe('cacheSize', () => {
it('returns 0 before loading', async () => {
mockGetAll.mockResolvedValue([]);

View File

@ -58,6 +58,18 @@ export function getAllProducts(): ProductDoc[] {
];
}
/**
* Products visible to a given user ("my projects"). Admins/super_admins see
* every product; everyone else sees only the products they own (`ownerId`).
* Owner-less legacy products are NOT shown to non-admins (they belong to no one
* in particular), which keeps a generic, multi-tenant deployment isolated.
*/
export function productsForUser(userId: string, isAdmin: boolean): ProductDoc[] {
const all = getAllProducts();
if (isAdmin) return all;
return all.filter(p => p.ownerId === userId);
}
/** Current cache size (for tests/health). */
export function cacheSize(): number {
return productCache.size;

View File

@ -9,8 +9,9 @@
import type { FastifyInstance } from 'fastify';
import { BadRequestError, ConflictError, NotFoundError } from '../../lib/errors.js';
import { extractAuth } from '../../lib/auth.js';
import * as repo from './repository.js';
import { loadProductCache, getAllProducts, getProduct } from './cache.js';
import { loadProductCache, getAllProducts, getProduct, productsForUser } from './cache.js';
import {
CreateProductSchema,
UpdateProductSchema,
@ -25,6 +26,15 @@ export async function productRoutes(app: FastifyInstance) {
return { products: getAllProducts() };
});
// List the authenticated caller's products ("my projects"). Owner-scoped:
// a normal user sees the products they own; admins/super_admins see them all.
// This is the per-user, multi-tenant view the dashboard switcher consumes.
app.get('/products/mine', async req => {
const auth = await extractAuth(req);
const isAdmin = auth.role === 'admin' || auth.role === 'super_admin';
return { products: productsForUser(auth.sub, isAdmin) };
});
// Get single product (served from cache)
app.get('/products/:id', async req => {
const { id } = req.params as { id: string };
@ -35,6 +45,7 @@ export async function productRoutes(app: FastifyInstance) {
// Create product (admin-only)
app.post('/products', async (req, reply) => {
const auth = await extractAuth(req);
const parsed = CreateProductSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
@ -49,6 +60,9 @@ export async function productRoutes(app: FastifyInstance) {
const doc: ProductDoc = {
id: input.productId,
...input,
// Attribute ownership to the creator (unless explicitly supplied) so it
// shows up under "my projects".
ownerId: input.ownerId ?? auth.sub,
createdAt: now,
updatedAt: now,
};

View File

@ -109,6 +109,10 @@ export interface ProductDoc {
websiteUrl: string;
status: ProductStatus;
prelaunchConfig?: PrelaunchConfig;
/** The user (JWT `sub`) who created/registered this product. Drives per-user
* scoping ("my projects") for a generic, multi-tenant platform. Optional for
* backward compatibility with products created before ownership tracking. */
ownerId?: string;
createdAt: string;
updatedAt: string;
}
@ -138,6 +142,7 @@ export const CreateProductSchema = z.object({
websiteUrl: z.string().url().or(z.literal('')).default(''),
status: z.enum(PRODUCT_STATUSES).default('active'),
prelaunchConfig: PrelaunchConfigSchema.optional(),
ownerId: z.string().optional(),
});
export const UpdateProductSchema = z.object({