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
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
196 lines
7.3 KiB
TypeScript
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);
|
|
}
|
|
}
|