dorfteich/apps/api/src/ponds/ponds.service.ts
Claude Fable 5 0c6494f209
All checks were successful
CD / Build and push images (push) Successful in 2m54s
CI / Lint, typecheck, test (push) Successful in 2m25s
CI / Auth e2e pack (push) Successful in 2m58s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 12s
Enforce permissions in API guards and retire interim access (#52)
Every route now declares its access rule explicitly and is enforced
through the shared resolution algorithm (permissions.md):

- PermissionGuard + decorators (@RequiresPondRole, @RequiresPagePermission,
  @RequiresAttachmentPermission, @AuthenticatedOnly) applied to every
  route; a route-enumeration test proves full coverage alongside
  @Public()/Site-Admin-guarded routes.
- 404/403 policy (documented in README conventions): denied reads answer
  404 (existence hiding), denied writes on readable things answer 403;
  trash views need write capability (ADR 0013).
- PermissionService resolves page/pond questions via the shared resolver,
  with an in-process pond-context cache (grants + label parents) that is
  invalidated on every grant/label-tree change and TTL-bounded as a
  multi-process safety net. Grant changes also fire pond_access_changed
  for collab revalidation (#39/#53).
- shared: pond-scope resolution (hasPondRole, canSeePond) next to the
  page resolver; grant wire schemas + GrantView.
- Owner Pond-Admin grants: migration backfill for all existing ponds,
  created transactionally with every new pond (shared + personal + seed).
- Grant CRUD under /ponds/:id/grants (pond_admin-gated) with structural
  and referential validation, last-admin protection, audit logs.
- InterimAccessService deleted; page lists, search, backlinks, phantom
  links, and trash listings are filtered per page through the resolver;
  collab tokens are now truly ro for readers.
- Fixture-matrix e2e (reader/editor/pond admin/foreign, label-deny,
  authenticated-subject, revoke-then-immediate-deny cache test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 16:31:41 +02:00

196 lines
7.3 KiB
TypeScript

import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import {
CreatePondInput,
PondView,
UpdatePondInput,
pondSettingsSchema,
slugify,
} from '@dorfteich/shared';
import { Pond, Prisma, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service';
import { QuotaService } from '../quotas/quota.service';
import { PondAccessNotifier } from './pond-access-notifier.service';
@Injectable()
export class PondsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionService,
private readonly quotas: QuotaService,
private readonly accessNotifier: PondAccessNotifier,
private readonly logger: PinoLogger,
) {
this.logger.setContext(PondsService.name);
}
viewOf(pond: Pond): PondView {
return {
id: pond.id,
slug: pond.slug,
name: pond.name,
description: pond.description,
type: pond.type === 'PERSONAL' ? 'personal' : 'shared',
ownerId: pond.ownerId,
// Stored settings hold only deviations; the schema fills defaults.
settings: pondSettingsSchema.parse(pond.settings ?? {}),
createdAt: pond.createdAt.toISOString(),
deletedAt: pond.deletedAt?.toISOString() ?? null,
};
}
/**
* Deterministic unique slug: the base slug, else `base-2`, `base-3`, …
* (never `-1`, so the unsuffixed original reads as number one). Deleted
* ponds keep their slug reserved — restore must not collide.
*/
async generateUniqueSlug(base: string, fallback: string): Promise<string> {
const slug = slugify(base) || slugify(fallback) || 'pond';
const taken = new Set(
(
await this.prisma.pond.findMany({
where: { OR: [{ slug }, { slug: { startsWith: `${slug}-` } }] },
select: { slug: true },
})
).map((row) => row.slug),
);
if (!taken.has(slug)) return slug;
for (let n = 2; ; n += 1) {
const candidate = `${slug}-${n}`;
if (!taken.has(candidate)) return candidate;
}
}
/**
* The owner's Pond Admin grant, created with the pond itself (issue #52) —
* access is decided solely by grants, so a pond without this row would be
* invisible even to its owner. Written directly (not through GrantsService)
* because the "no pond_admin grants on personal ponds" rule is about
* *additional* admins; the owner is the one admin every pond starts with.
*/
private static ownerAdminGrant(pondId: string, ownerId: string): Prisma.RoleGrantCreateInput {
return {
pond: { connect: { id: pondId } },
subjectType: 'USER',
subjectId: ownerId,
role: 'POND_ADMIN',
scopeType: 'POND',
scopeId: null,
effect: 'ALLOW',
createdBy: ownerId,
};
}
async createShared(owner: User, input: CreatePondInput): Promise<PondView> {
const slug = await this.generateUniqueSlug(input.name, owner.username);
// Quota check and create share one transaction — the advisory lock in
// the check makes concurrent creations by the same user race-safe. The
// owner grant joins it so no pond ever exists without its admin.
const pond = await this.prisma.$transaction(async (tx) => {
await this.quotas.assertCanCreateSharedPond(tx, owner.id);
const created = await tx.pond.create({
data: {
slug,
name: input.name,
description: input.description,
type: 'SHARED',
ownerId: owner.id,
},
});
await tx.roleGrant.create({ data: PondsService.ownerAdminGrant(created.id, owner.id) });
return created;
});
this.logger.info({ pondId: pond.id, ownerId: owner.id }, 'audit: pond created');
return this.viewOf(pond);
}
/**
* Creates the personal pond on first e-mail verification (issue #21).
* Idempotent: a user has at most one personal pond, even a trashed one
* blocks re-creation (restore instead of duplicating).
*/
async ensurePersonalPond(user: User): Promise<void> {
const existing = await this.prisma.pond.findFirst({
where: { ownerId: user.id, type: 'PERSONAL' },
select: { id: true },
});
if (existing) return;
const slug = await this.generateUniqueSlug(user.displayName, user.username);
const pond = await this.prisma.$transaction(async (tx) => {
const created = await tx.pond.create({
data: { slug, name: user.displayName, type: 'PERSONAL', ownerId: user.id },
});
await tx.roleGrant.create({ data: PondsService.ownerAdminGrant(created.id, user.id) });
return created;
});
this.logger.info({ pondId: pond.id, ownerId: user.id }, 'audit: personal pond created');
}
async listVisible(user: User): Promise<PondView[]> {
const ponds = await this.prisma.pond.findMany({
where: { ...(await this.permissions.visiblePondsWhere(user)), deletedAt: null },
orderBy: { name: 'asc' },
});
return ponds.map((pond) => this.viewOf(pond));
}
/** Existence/permission are the guard's job (#52); this only loads. */
async getVisibleBySlug(_user: User, slug: string): Promise<PondView> {
const pond = await this.prisma.pond.findFirst({ where: { slug, deletedAt: null } });
if (!pond) throw new NotFoundException();
return this.viewOf(pond);
}
async update(_user: User, id: string, input: UpdatePondInput): Promise<PondView> {
const pond = await this.prisma.pond.findFirst({ where: { id, deletedAt: null } });
if (!pond) throw new NotFoundException();
const settings =
input.sidebarSort === undefined
? undefined
: { ...(pond.settings as object), sidebarSort: input.sidebarSort };
const updated = await this.prisma.pond.update({
where: { id },
data: { name: input.name, description: input.description, settings },
});
return this.viewOf(updated);
}
async softDelete(user: User, id: string): Promise<void> {
const pond = await this.prisma.pond.findFirst({ where: { id, deletedAt: null } });
if (!pond) throw new NotFoundException();
if (pond.type === 'PERSONAL') {
// The personal pond is the account's home — it cannot be trashed.
throw new ForbiddenException({ code: 'personal_pond_undeletable' });
}
await this.prisma.pond.update({
where: { id },
data: { deletedAt: new Date(), deletedBy: user.id },
});
this.logger.info({ pondId: id, userId: user.id }, 'audit: pond trashed');
// Revalidate any live collaboration sessions on the pond's pages
// (issue #39); grant changes fire the same notification (#52/#53).
await this.accessNotifier.notifyAccessChanged(id);
}
/** Site-Admin-only (guarded at the controller): the pond-level trash. */
async listTrash(): Promise<PondView[]> {
const ponds = await this.prisma.pond.findMany({
where: { deletedAt: { not: null } },
orderBy: { deletedAt: 'desc' },
});
return ponds.map((pond) => this.viewOf(pond));
}
/** Site-Admin-only (guarded at the controller). */
async restore(id: string): Promise<PondView> {
const pond = await this.prisma.pond.update({
where: { id },
data: { deletedAt: null, deletedBy: null },
});
this.logger.info({ pondId: id }, 'audit: pond restored');
return this.viewOf(pond);
}
}