Compare commits

...

3 Commits

Author SHA1 Message Date
agent_coder 670a627129 test(share): int-tests lock approved-baseline rollback atomicity (#569)
Force the baseline mint (saveHistory) to throw inside createShare(approved)
and updateShare(->approved) and assert the shares row rolled back (no row on
create; publishedMode stays 'live' on update) and no orphan baseline. These
fail if the mint runs outside the transaction, locking the F1 fail-open fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 05:57:41 +03:00
agent_coder 8c83e2d201 fix(share): atomic approved-baseline mint + extract PublishedMode type (#569)
F1: wrap the share-row write and ensureApprovedBaseline() in one executeTx
transaction in both createShare and updateShare, threading trx through every
repo call. Closes the reachable 'approved share without baseline' fail-open
state that served the live draft.

F3: extract shared PublishedMode type + DEFAULT_PUBLISHED_MODE into
published-mode.constants.ts (mirroring PageHistoryKind); reference from the
service and DTO; mirror the type on the client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 04:44:44 +03:00
agent_coder 7062bae516 feat(share): Stage B — "approved" publish-only-the-saved-version mode (#370)
A share can now be published in 'approved' mode: public readers by URL see
the last manually-saved version (page_history.kind='manual') instead of the
live draft. The content swap lives in the single canonical resolver
resolveReadableSharePage, so EVERY public read path is frozen consistently —
the shared-page view, the SEO controller, AND the share assistant's page-read
tool (owner decision: a visitor and the assistant see the same bytes). Swaps
only content+title; page.id/workspaceId stay LIVE so attachment tokens keep
minting for the right owner. MVP residue kept live: the assistant's page
LIST/TREE tools and transclusions.

- migration 20260712T130000: shares.published_mode varchar(20) NOT NULL
  DEFAULT 'live' (additive, standalone; existing shares stay live)
- db.d.ts: publishedMode on Shares (Share/Insertable/Updatable derive)
- page-history.repo: findLatestByPageIdAndKind(pageId, kind, {includeContent})
- share.repo: thread publishedMode through baseFields/insert/update
- share.service.getShareForPage: 4-point CTE threading (anchor + union +
  returned object + repo allowlist) — type-guarded, so dropping any point
  fails the build/tests
- createShare/updateShare: accept publishedMode; reject approved+includeSubPages
  (BadRequestException); auto-mint the first manual baseline ON ENABLE
- share.dto: publishedMode @IsOptional @IsIn(['live','approved'])
- client: "Publish only the saved version" toggle (XOR with sub-pages),
  version caption + "unsaved changes newer than the published version" hint;
  share types/api carry publishedMode
- tests: resolve-swap (content+title swapped, id+workspaceId preserved),
  assistant getSharePage sees frozen content, getShareForPage threading
  (direct + descendant), baseline auto-create, XOR reject, migration up/down

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 04:44:44 +03:00
16 changed files with 828 additions and 25 deletions
@@ -29,6 +29,7 @@ import ShareAliasSection from "@/features/share/components/share-alias-section.t
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query.ts";
interface ShareModalProps { interface ShareModalProps {
readOnly: boolean; readOnly: boolean;
@@ -54,6 +55,33 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
// if level is greater than zero, then it is a descendant page from a shared page // if level is greater than zero, then it is a descendant page from a shared page
const isDescendantShared = share && share.level > 0; const isDescendantShared = share && share.level > 0;
// #370 Stage B — "publish only the saved version". Mirrors the server XOR:
// approved and includeSubPages are mutually exclusive.
const isApproved = share?.publishedMode === "approved";
const includeSubPages = share?.includeSubPages ?? false;
// Resolve the latest MANUAL version (the one an approved share publishes) so
// the modal can caption it and warn when the live draft has drifted ahead.
// Only fetched for a directly-shared page whose modal is actually open.
const { data: historyData } = usePageHistoryListQuery(
pageIsShared ? pageId : "",
);
const latestManual = useMemo(() => {
const items = historyData?.pages?.flatMap((p) => p.items) ?? [];
// The list is newest-first; the first manual row is the published version.
return items.find((h) => h.kind === "manual") ?? null;
}, [historyData]);
// The live draft has edits newer than the published saved version when the
// page was updated after that manual snapshot was taken.
const hasUnpublishedEdits = useMemo(() => {
if (!isApproved || !latestManual || !page?.updatedAt) return false;
return (
new Date(page.updatedAt).getTime() >
new Date(latestManual.createdAt).getTime()
);
}, [isApproved, latestManual, page?.updatedAt]);
const publicLink = `${getAppUrl()}/share/${share?.key}/p/${pageSlug}`; const publicLink = `${getAppUrl()}/share/${share?.key}/p/${pageSlug}`;
const [isPagePublic, setIsPagePublic] = useState<boolean>(false); const [isPagePublic, setIsPagePublic] = useState<boolean>(false);
@@ -115,6 +143,23 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
} }
}; };
// #370 Stage B — toggle between publishing the live draft ('live') and the
// last saved version ('approved'). The server rejects approved + sub-pages, so
// the UI keeps the two toggles mutually exclusive (see the disabled props).
const handlePublishedModeChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const value = event.currentTarget.checked;
try {
await updateShareMutation.mutateAsync({
shareId: share.id,
publishedMode: value ? "approved" : "live",
});
} catch {
// query invalidation will revert the UI
}
};
const shareLink = useMemo( const shareLink = useMemo(
() => ( () => (
<Group my="sm" gap={4} wrap="nowrap"> <Group my="sm" gap={4} wrap="nowrap">
@@ -238,11 +283,42 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
<Switch <Switch
onChange={handleSubPagesChange} onChange={handleSubPagesChange}
checked={share.includeSubPages} checked={includeSubPages}
size="xs" size="xs"
disabled={readOnly} // XOR with approved mode: a version-frozen share cannot also
// publish a whole sub-tree.
disabled={readOnly || isApproved}
/> />
</Group> </Group>
<Group justify="space-between" wrap="nowrap" gap="xl" mt="sm">
<div>
<Text size="sm">
{t("Publish only the saved version")}
</Text>
<Text size="xs" c="dimmed">
{isApproved
? t(
"Visitors see the last saved version, not live edits",
)
: t("Visitors see live edits as you make them")}
</Text>
</div>
<Switch
onChange={handlePublishedModeChange}
checked={isApproved}
size="xs"
// XOR with sub-pages: hidden intent is enforced by disabling
// this toggle whenever sub-pages are shared.
disabled={readOnly || includeSubPages}
/>
</Group>
{isApproved && hasUnpublishedEdits && (
<Text size="xs" c="orange" mt={4}>
{t(
"You have unsaved changes newer than the published version",
)}
</Text>
)}
<Group justify="space-between" wrap="nowrap" gap="xl" mt="sm"> <Group justify="space-between" wrap="nowrap" gap="xl" mt="sm">
<div> <div>
<Text size="sm">{t("Search engine indexing")}</Text> <Text size="sm">{t("Search engine indexing")}</Text>
@@ -1,11 +1,20 @@
import { IPage } from "@/features/page/types/page.types.ts"; import { IPage } from "@/features/page/types/page.types.ts";
// #370 Stage B — share publication mode union + default. Mirrors the server-side
// PublishedMode in apps/server/src/core/share/published-mode.constants.ts (the
// client cannot import server code); keep the two in sync.
export type PublishedMode = "live" | "approved";
export const DEFAULT_PUBLISHED_MODE: PublishedMode = "live";
export interface IShare { export interface IShare {
id: string; id: string;
key: string; key: string;
pageId: string; pageId: string;
includeSubPages: boolean; includeSubPages: boolean;
searchIndexing: boolean; searchIndexing: boolean;
// #370 Stage B — 'live' serves the current draft; 'approved' serves the last
// manually-saved version. Mutually exclusive with includeSubPages.
publishedMode: PublishedMode;
creatorId: string; creatorId: string;
spaceId: string; spaceId: string;
workspaceId: string; workspaceId: string;
@@ -75,6 +84,7 @@ export interface ICreateShare {
pageId?: string; pageId?: string;
includeSubPages?: boolean; includeSubPages?: boolean;
searchIndexing?: boolean; searchIndexing?: boolean;
publishedMode?: PublishedMode;
} }
export type IUpdateShare = ICreateShare & { shareId: string; pageId?: string }; export type IUpdateShare = ICreateShare & { shareId: string; pageId?: string };
@@ -210,6 +210,55 @@ describe('PublicShareChatToolsService.forShare', () => {
}); });
}); });
describe('getSharePage under approved publication (owner decision: assistant sees the frozen saved version)', () => {
it('reads the FROZEN saved content/title returned by the boundary, not a live re-fetch', async () => {
// #370 Stage B owner decision: ALL public reads — including the share
// assistant's page-read tool — see the published saved version, so a
// visitor and the assistant see the same bytes. The content swap lives in
// resolveReadableSharePage (the single canonical boundary), so the tool
// simply consumes the frozen { page } it returns: the assistant must never
// re-read the live draft behind the boundary's back.
const frozenContent = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'FROZEN saved version body' }],
},
],
};
// The boundary already swapped content+title to the manual version while
// preserving the live id (its unit test pins that); the tool sees this.
const frozenPage = {
id: 'page-1',
title: 'Saved Version Title',
deletedAt: null,
content: frozenContent,
};
const { svc, shareService } = makeService({
resolveReadableSharePage: jest
.fn()
.mockResolvedValue({ share: { id: 'SHARE-A' }, page: frozenPage }),
});
// updatePublicAttachments sanitizes the FROZEN page it was handed.
shareService.updatePublicAttachments.mockResolvedValue(frozenContent);
const tools = svc.forShare('SHARE-A', 'ws-1');
const out = (await (tools.getSharePage as unknown as ToolExec).execute({
pageId: 'page-1',
})) as { title: string; markdown: string };
// Sanitizer is handed the frozen page (never a separate live fetch).
expect(shareService.updatePublicAttachments).toHaveBeenCalledWith(
frozenPage,
);
// Title + body come from the saved (frozen) version.
expect(out.title).toBe('Saved Version Title');
expect(out.markdown).toContain('FROZEN saved version body');
});
});
describe('getSharePage non-resolving page (deleted / restricted / out-of-share)', () => { describe('getSharePage non-resolving page (deleted / restricted / out-of-share)', () => {
it('resolveReadableSharePage returns null (e.g. soft-deleted page) => generic error, NO content sanitized/returned', async () => { it('resolveReadableSharePage returns null (e.g. soft-deleted page) => generic error, NO content sanitized/returned', async () => {
// The canonical boundary 404s a soft-deleted / restricted / out-of-tree // The canonical boundary 404s a soft-deleted / restricted / out-of-tree
@@ -1,10 +1,12 @@
import { import {
IsBoolean, IsBoolean,
IsIn,
IsNotEmpty, IsNotEmpty,
IsOptional, IsOptional,
IsString, IsString,
IsUUID, IsUUID,
} from 'class-validator'; } from 'class-validator';
import { PublishedMode } from '../published-mode.constants';
export class CreateShareDto { export class CreateShareDto {
@IsString() @IsString()
@@ -18,6 +20,13 @@ export class CreateShareDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
searchIndexing: boolean; searchIndexing: boolean;
// #370 Stage B — publication mode. 'live' (default) serves the current draft;
// 'approved' serves the last manually-saved version (page_history.kind
// ='manual'). Mutually exclusive with includeSubPages (enforced server-side).
@IsOptional()
@IsIn(['live', 'approved'])
publishedMode?: PublishedMode;
} }
export class UpdateShareDto extends CreateShareDto { export class UpdateShareDto extends CreateShareDto {
@@ -0,0 +1,6 @@
// #370 Stage B — the share publication mode union + its default, shared across
// the DTO and the service (mirrors the PageHistoryKind pattern in
// collaboration/constants.ts). The client mirrors this union separately since
// it cannot import server code.
export type PublishedMode = 'live' | 'approved';
export const DEFAULT_PUBLISHED_MODE: PublishedMode = 'live';
@@ -39,6 +39,7 @@ function buildService() {
tokenService as any, tokenService as any,
{} as any, // transclusionService (unused) {} as any, // transclusionService (unused)
workspaceRepo as any, workspaceRepo as any,
{} as any, // pageHistoryRepo (unused on this path)
); );
} }
@@ -39,6 +39,7 @@ function buildService(over: {
{} as any, // tokenService {} as any, // tokenService
{} as any, // transclusionService {} as any, // transclusionService
{} as any, // workspaceRepo {} as any, // workspaceRepo
{} as any, // pageHistoryRepo
); );
jest jest
@@ -62,6 +62,7 @@ function buildService(opts: {
tokenService as any, tokenService as any,
{} as any, // transclusionService (unused) {} as any, // transclusionService (unused)
workspaceRepo as any, workspaceRepo as any,
{} as any, // pageHistoryRepo (unused on this path)
); );
// getSharedPage resolves the share via getShareForPage (a raw db query). // getSharedPage resolves the share via getShareForPage (a raw db query).
@@ -181,6 +182,7 @@ describe('ShareService.lookupTransclusionForShare htmlEmbed kill-switch (real co
tokenService as any, tokenService as any,
transclusionService as any, transclusionService as any,
workspaceRepo as any, workspaceRepo as any,
{} as any, // pageHistoryRepo (unused on this path)
); );
// isSharingAllowed and getShareForPage hit the raw db; stub them so the // isSharingAllowed and getShareForPage hit the raw db; stub them so the
@@ -23,6 +23,9 @@ function buildService(over: {
resolvedShare?: unknown; resolvedShare?: unknown;
page?: unknown; page?: unknown;
restricted?: boolean; restricted?: boolean;
// #370 Stage B — the latest manual page-history row returned for the approved
// swap. `undefined` (the default) means the repo finds no manual version.
manualHistory?: unknown;
} = {}) { } = {}) {
const pageRepo = { const pageRepo = {
findById: jest.fn(async () => findById: jest.fn(async () =>
@@ -34,6 +37,9 @@ function buildService(over: {
const pagePermissionRepo = { const pagePermissionRepo = {
hasRestrictedAncestor: jest.fn(async () => over.restricted ?? false), hasRestrictedAncestor: jest.fn(async () => over.restricted ?? false),
}; };
const pageHistoryRepo = {
findLatestByPageIdAndKind: jest.fn(async () => over.manualHistory ?? undefined),
};
const service = new ShareService( const service = new ShareService(
{} as any, // shareRepo (unused on this path) {} as any, // shareRepo (unused on this path)
@@ -43,6 +49,7 @@ function buildService(over: {
{} as any, // tokenService (unused) {} as any, // tokenService (unused)
{} as any, // transclusionService (unused) {} as any, // transclusionService (unused)
{} as any, // workspaceRepo (unused) {} as any, // workspaceRepo (unused)
pageHistoryRepo as any,
); );
jest jest
@@ -53,7 +60,7 @@ function buildService(over: {
: { id: SHARE, pageId: PAGE, spaceId: 'space-1' }) as any, : { id: SHARE, pageId: PAGE, spaceId: 'space-1' }) as any,
); );
return { service, pageRepo, pagePermissionRepo }; return { service, pageRepo, pagePermissionRepo, pageHistoryRepo };
} }
describe('ShareService.resolveReadableSharePage (the share-access boundary)', () => { describe('ShareService.resolveReadableSharePage (the share-access boundary)', () => {
@@ -133,4 +140,90 @@ describe('ShareService.resolveReadableSharePage (the share-access boundary)', ()
includeCreator: true, includeCreator: true,
}); });
}); });
// #370 Stage B — "approved" publication freezes the served content/title to
// the latest manual page-history version, WITHOUT touching page.id or
// page.workspaceId (downstream attachment tokens are minted off those, so
// freezing them would mint tokens for the wrong owner).
describe('approved publication mode (content freeze)', () => {
const LIVE_PAGE = {
id: PAGE,
workspaceId: WS,
deletedAt: null,
title: 'Live draft title',
content: { type: 'doc', live: true },
};
const MANUAL = {
title: 'Saved version title',
content: { type: 'doc', saved: true },
};
it('swaps content + title to the latest manual version but PRESERVES id + workspaceId', async () => {
const { service, pageHistoryRepo } = buildService({
resolvedShare: {
id: SHARE,
pageId: PAGE,
spaceId: 'space-1',
publishedMode: 'approved',
},
page: { ...LIVE_PAGE },
manualHistory: MANUAL,
});
const out = await service.resolveReadableSharePage(SHARE, PAGE, WS);
expect(out).not.toBeNull();
// Content + title come from the saved version.
expect(out!.page.content).toEqual(MANUAL.content);
expect(out!.page.title).toBe(MANUAL.title);
// id + workspaceId stay LIVE (attachment-token ownership must not shift).
expect(out!.page.id).toBe(LIVE_PAGE.id);
expect(out!.page.workspaceId).toBe(LIVE_PAGE.workspaceId);
// Resolved against the LIVE page id and the manual tier, with content.
expect(pageHistoryRepo.findLatestByPageIdAndKind).toHaveBeenCalledWith(
LIVE_PAGE.id,
'manual',
{ includeContent: true },
);
});
it('falls back to live content when no manual version exists yet', async () => {
const { service, pageHistoryRepo } = buildService({
resolvedShare: {
id: SHARE,
pageId: PAGE,
spaceId: 'space-1',
publishedMode: 'approved',
},
page: { ...LIVE_PAGE },
// manualHistory omitted → repo returns undefined
});
const out = await service.resolveReadableSharePage(SHARE, PAGE, WS);
expect(out).not.toBeNull();
expect(out!.page.content).toEqual(LIVE_PAGE.content);
expect(out!.page.title).toBe(LIVE_PAGE.title);
expect(pageHistoryRepo.findLatestByPageIdAndKind).toHaveBeenCalled();
});
it('does NOT consult page-history for a live share', async () => {
const { service, pageHistoryRepo } = buildService({
resolvedShare: {
id: SHARE,
pageId: PAGE,
spaceId: 'space-1',
publishedMode: 'live',
},
page: { ...LIVE_PAGE },
manualHistory: MANUAL,
});
const out = await service.resolveReadableSharePage(SHARE, PAGE, WS);
expect(out).not.toBeNull();
expect(out!.page.content).toEqual(LIVE_PAGE.content);
expect(pageHistoryRepo.findLatestByPageIdAndKind).not.toHaveBeenCalled();
});
});
}); });
@@ -33,6 +33,7 @@ function buildService() {
tokenService as any, tokenService as any,
{} as any, // transclusionService (unused) {} as any, // transclusionService (unused)
workspaceRepo as any, workspaceRepo as any,
{} as any, // pageHistoryRepo (unused on this path)
); );
} }
+163 -22
View File
@@ -6,7 +6,12 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { CreateShareDto, ShareInfoDto, UpdateShareDto } from './dto/share.dto'; import { CreateShareDto, ShareInfoDto, UpdateShareDto } from './dto/share.dto';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils';
import {
PublishedMode,
DEFAULT_PUBLISHED_MODE,
} from './published-mode.constants';
import { nanoIdGen } from '../../common/helpers'; import { nanoIdGen } from '../../common/helpers';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { TokenService } from '../auth/services/token.service'; import { TokenService } from '../auth/services/token.service';
@@ -19,6 +24,7 @@ import {
} from '../../common/helpers/prosemirror/utils'; } from '../../common/helpers/prosemirror/utils';
import { Node } from '@tiptap/pm/model'; import { Node } from '@tiptap/pm/model';
import { ShareRepo } from '@docmost/db/repos/share/share.repo'; import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { updateAttachmentAttr } from './share.util'; import { updateAttachmentAttr } from './share.util';
import { Page } from '@docmost/db/types/entity.types'; import { Page } from '@docmost/db/types/entity.types';
@@ -44,6 +50,7 @@ export class ShareService {
private readonly tokenService: TokenService, private readonly tokenService: TokenService,
private readonly transclusionService: TransclusionService, private readonly transclusionService: TransclusionService,
private readonly workspaceRepo: WorkspaceRepo, private readonly workspaceRepo: WorkspaceRepo,
private readonly pageHistoryRepo: PageHistoryRepo,
) {} ) {}
/** /**
@@ -92,20 +99,48 @@ export class ShareService {
}) { }) {
const { authUserId, workspaceId, page, createShareDto } = opts; const { authUserId, workspaceId, page, createShareDto } = opts;
try { const includeSubPages = createShareDto.includeSubPages ?? false;
const shares = await this.shareRepo.findByPageId(page.id); const publishedMode = createShareDto.publishedMode ?? DEFAULT_PUBLISHED_MODE;
if (shares) {
return shares;
}
return await this.shareRepo.insertShare({ // #370 Stage B — 'approved' freezes a SINGLE page to its saved version; a
key: nanoIdGen().toLowerCase(), // sub-tree cannot be version-frozen in the MVP, so the two are mutually
pageId: page.id, // exclusive. Thrown before the try so the specific 400 reaches the client
includeSubPages: createShareDto.includeSubPages ?? false, // instead of being flattened to the generic "Failed to share page".
searchIndexing: createShareDto.searchIndexing ?? false, this.assertPublishedModeCompatible(publishedMode, includeSubPages);
creatorId: authUserId,
spaceId: page.spaceId, try {
workspaceId, // Atomic share-write + baseline mint (F1): the share row insert and the
// approved baseline are one transaction, so an approved share can never
// durably exist WITHOUT its baseline. That "approved without baseline"
// state is what the public read path fails OPEN to (serving the live
// draft) — making it unreachable closes that confidentiality leak.
return await executeTx(this.db, async (trx) => {
const shares = await this.shareRepo.findByPageId(page.id, { trx });
if (shares) {
return shares;
}
const share = await this.shareRepo.insertShare(
{
key: nanoIdGen().toLowerCase(),
pageId: page.id,
includeSubPages,
searchIndexing: createShareDto.searchIndexing ?? false,
publishedMode,
creatorId: authUserId,
spaceId: page.spaceId,
workspaceId,
},
trx,
);
// Guarantee an approved share always has a saved version to serve: mint
// the first manual baseline from the page's current content on enable.
if (publishedMode === 'approved') {
await this.ensureApprovedBaseline(page.id, trx);
}
return share;
}); });
} catch (err) { } catch (err) {
this.logger.error(err); this.logger.error(err);
@@ -114,20 +149,99 @@ export class ShareService {
} }
async updateShare(shareId: string, updateShareDto: UpdateShareDto) { async updateShare(shareId: string, updateShareDto: UpdateShareDto) {
// Resolve the current share so the XOR gate can be evaluated against the
// EFFECTIVE post-update state (either field may be absent from the DTO).
const current = await this.shareRepo.findById(shareId);
if (!current) {
throw new NotFoundException('Share not found');
}
const effectiveIncludeSubPages =
updateShareDto.includeSubPages ?? current.includeSubPages ?? false;
const effectivePublishedMode = (updateShareDto.publishedMode ??
current.publishedMode ??
DEFAULT_PUBLISHED_MODE) as PublishedMode;
// #370 Stage B — enforce the approved/includeSubPages XOR on the merged
// state. Thrown before the try so the specific 400 reaches the client.
this.assertPublishedModeCompatible(
effectivePublishedMode,
effectiveIncludeSubPages,
);
try { try {
return this.shareRepo.updateShare( // Atomic share-write + baseline mint (F1): the share update and the
{ // approved baseline commit together, so a share can never durably land in
includeSubPages: updateShareDto.includeSubPages, // approved mode WITHOUT a baseline. That gap is what the public read path
searchIndexing: updateShareDto.searchIndexing, // fails OPEN to (serving the live draft) — one transaction makes it
}, // unreachable and closes the confidentiality leak.
shareId, return await executeTx(this.db, async (trx) => {
); const updated = await this.shareRepo.updateShare(
{
includeSubPages: updateShareDto.includeSubPages,
searchIndexing: updateShareDto.searchIndexing,
publishedMode: updateShareDto.publishedMode,
},
shareId,
trx,
);
// On (or while) enabling approved mode, ensure a manual baseline exists
// so public readers always have a saved version to serve. Idempotent: a
// no-op when the page already has a manual history row.
if (effectivePublishedMode === 'approved') {
await this.ensureApprovedBaseline(current.pageId, trx);
}
return updated;
});
} catch (err) { } catch (err) {
this.logger.error(err); this.logger.error(err);
throw new BadRequestException('Failed to update share'); throw new BadRequestException('Failed to update share');
} }
} }
/**
* #370 Stage B — 'approved' publication is mutually exclusive with
* includeSubPages: a whole sub-tree cannot be version-frozen in the MVP.
*/
private assertPublishedModeCompatible(
publishedMode: PublishedMode,
includeSubPages: boolean,
): void {
if (publishedMode === 'approved' && includeSubPages) {
throw new BadRequestException(
'Approved publication cannot be combined with including sub-pages',
);
}
}
/**
* #370 Stage B — guarantee an approved share has a saved version to serve.
* When the page has NO manual history row yet, snapshot its CURRENT content as
* the first manual version. Idempotent: returns early when a manual version
* already exists, so it is safe to call on every approved update.
*/
private async ensureApprovedBaseline(
pageId: string,
trx: KyselyTransaction,
): Promise<void> {
const existing = await this.pageHistoryRepo.findLatestByPageIdAndKind(
pageId,
'manual',
{ trx },
);
if (existing) return;
const page = await this.pageRepo.findById(pageId, {
includeContent: true,
trx,
});
if (!page) return;
await this.pageHistoryRepo.saveHistory(page, { kind: 'manual', trx });
}
/** /**
* THE share access boundary in ONE place. * THE share access boundary in ONE place.
* *
@@ -173,7 +287,7 @@ export class ShareService {
// passes no shareId (it resolved the share from the page itself). // passes no shareId (it resolved the share from the page itself).
if (shareId != null && share.id !== shareId) return null; if (shareId != null && share.id !== shareId) return null;
const page = await this.pageRepo.findById(pageId, { let page = await this.pageRepo.findById(pageId, {
includeContent: true, includeContent: true,
includeCreator: opts?.includeCreator ?? false, includeCreator: opts?.includeCreator ?? false,
}); });
@@ -185,6 +299,30 @@ export class ShareService {
return null; return null;
} }
// #370 Stage B — "approved" publication freezes what public readers see to
// the LAST manually-saved version (page_history.kind='manual'), not the live
// draft. This is the SINGLE canonical resolver every public read funnels
// through (the shared-page view, the SEO controller, AND the share
// assistant's page-read tool), so the swap here freezes ALL of them
// consistently — a public visitor and the assistant see the same bytes.
//
// Swap ONLY content + title. page.id and page.workspaceId stay LIVE on
// purpose: downstream updatePublicAttachments mints per-attachment tokens
// off page.id/workspaceId, so freezing those would mint tokens for the
// wrong owner. The SEO controller reads resolved.page.title, so the frozen
// title flows through automatically. If no manual version exists yet (rare —
// enabling an approved share auto-creates a baseline), fall back to live.
if (share.publishedMode === 'approved') {
const hist = await this.pageHistoryRepo.findLatestByPageIdAndKind(
page.id,
'manual',
{ includeContent: true },
);
if (hist) {
page = { ...page, content: hist.content, title: hist.title };
}
}
return { share, page }; return { share, page };
} }
@@ -302,6 +440,7 @@ export class ShareService {
'shares.key as shareKey', 'shares.key as shareKey',
'shares.includeSubPages', 'shares.includeSubPages',
'shares.searchIndexing', 'shares.searchIndexing',
'shares.publishedMode',
'shares.creatorId', 'shares.creatorId',
'shares.spaceId', 'shares.spaceId',
'shares.workspaceId', 'shares.workspaceId',
@@ -326,6 +465,7 @@ export class ShareService {
's.key as shareKey', 's.key as shareKey',
's.includeSubPages', 's.includeSubPages',
's.searchIndexing', 's.searchIndexing',
's.publishedMode',
's.creatorId', 's.creatorId',
's.spaceId', 's.spaceId',
's.workspaceId', 's.workspaceId',
@@ -355,6 +495,7 @@ export class ShareService {
key: share.shareKey, key: share.shareKey,
includeSubPages: share.includeSubPages, includeSubPages: share.includeSubPages,
searchIndexing: share.searchIndexing, searchIndexing: share.searchIndexing,
publishedMode: share.publishedMode,
pageId: share.id, pageId: share.id,
creatorId: share.creatorId, creatorId: share.creatorId,
spaceId: share.spaceId, spaceId: share.spaceId,
@@ -0,0 +1,29 @@
import { type Kysely, sql } from 'kysely';
/**
* #370 Stage B — share "approved" (publish-only-the-saved-version) mode.
*
* Adds `shares.published_mode`, the per-share switch between:
* - 'live' — public readers see the current draft (legacy behavior)
* - 'approved' — public readers see the LAST manually-saved version
* (page_history.kind='manual'), not the live draft
*
* NOT NULL with a 'live' default so every existing share keeps its current
* (live) semantics with no data backfill. Standalone + single-concern +
* additive per the #363 crash-loop rule — no coupled baseline write here;
* the first manual baseline is minted by the service on enable, not by this
* migration. Stored as a short varchar to stay forward-compatible without an
* enum migration.
*/
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('shares')
.addColumn('published_mode', 'varchar(20)', (col) =>
col.notNull().defaultTo(sql`'live'`),
)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('shares').dropColumn('published_mode').execute();
}
@@ -219,6 +219,36 @@ export class PageHistoryRepo {
.executeTakeFirst(); .executeTakeFirst();
} }
/**
* #370 Stage B — resolve the latest snapshot of a page at a given
* intentionality tier. Used by the "approved" share mode to serve the last
* manually-saved version (`kind='manual'`) to public readers instead of the
* live draft. Modeled on `findPageLastHistory` with an added `kind` filter and
* the same deterministic `(createdAt desc, id desc)` tie-break, so two rows
* sharing a createdAt still resolve to a single stable "latest".
*/
async findLatestByPageIdAndKind(
pageId: string,
kind: PageHistoryKind,
opts?: {
includeContent?: boolean;
trx?: KyselyTransaction;
},
) {
const db = dbOrTx(this.db, opts?.trx);
return await db
.selectFrom('pageHistory')
.select(this.baseFields)
.$if(opts?.includeContent, (qb) => qb.select('content'))
.where('pageId', '=', pageId)
.where('kind', '=', kind)
.limit(1)
.orderBy('createdAt', 'desc')
.orderBy('id', 'desc')
.executeTakeFirst();
}
withLastUpdatedBy(eb: ExpressionBuilder<DB, 'pageHistory'>) { withLastUpdatedBy(eb: ExpressionBuilder<DB, 'pageHistory'>) {
return jsonObjectFrom( return jsonObjectFrom(
eb eb
@@ -28,6 +28,7 @@ export class ShareRepo {
'pageId', 'pageId',
'includeSubPages', 'includeSubPages',
'searchIndexing', 'searchIndexing',
'publishedMode',
'creatorId', 'creatorId',
'spaceId', 'spaceId',
'workspaceId', 'workspaceId',
+1
View File
@@ -340,6 +340,7 @@ export interface Shares {
includeSubPages: Generated<boolean | null>; includeSubPages: Generated<boolean | null>;
key: string; key: string;
pageId: string | null; pageId: string | null;
publishedMode: Generated<string>;
searchIndexing: Generated<boolean | null>; searchIndexing: Generated<boolean | null>;
spaceId: string; spaceId: string;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
@@ -0,0 +1,353 @@
import { Kysely } from 'kysely';
import { randomUUID } from 'node:crypto';
import { BadRequestException } from '@nestjs/common';
import { ShareService } from 'src/core/share/share.service';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
import * as publishedModeMigration from 'src/database/migrations/20260712T130000-shares-published-mode';
import {
getTestDb,
destroyTestDb,
createWorkspace,
createSpace,
createPage,
} from './db';
/**
* #370 Stage B — "approved" (publish-only-the-saved-version) share mode, against
* real Postgres (so the migration column + the hand-written recursive-CTE
* threading in getShareForPage are exercised for real, not mocked).
*
* Guards:
* - getShareForPage carries `publishedMode` through ALL of the CTE points
* (anchor select, recursive-union select, returned object) — a direct share
* AND a descendant (union) share both read it back. If ANY of the 4 threading
* points is dropped, one of these reddens.
* - createShare / updateShare auto-mint the first manual baseline ON ENABLE.
* - approved + includeSubPages is rejected (BadRequestException) on both paths.
* - resolveReadableSharePage under approved swaps content+title to the manual
* version while PRESERVING page.id + workspaceId.
*/
describe('share published_mode (approved) [integration]', () => {
let db: Kysely<any>;
let service: ShareService;
let shareRepo: ShareRepo;
let pageHistoryRepo: PageHistoryRepo;
let wsId: string;
let spaceId: string;
// shares.creator_id / page_history.last_updated_by_id are uuid columns; use
// null (nullable) rather than a synthetic non-uuid id.
const USER = null as any;
// resolveReadableSharePage runs the restricted-ancestor gate; keep it open.
const pagePermissionRepo = {
hasRestrictedAncestor: async () => false,
};
beforeAll(async () => {
db = getTestDb();
shareRepo = new ShareRepo(db as any, {} as any);
const pageRepo = new PageRepo(db as any, {} as any, {} as any);
pageHistoryRepo = new PageHistoryRepo(db as any);
service = new ShareService(
shareRepo as any,
pageRepo as any,
pagePermissionRepo as any,
db as any,
{} as any, // tokenService — no attachments in these docs
{} as any, // transclusionService
{ findById: async () => ({ settings: {} }) } as any, // workspaceRepo
pageHistoryRepo as any,
);
wsId = (await createWorkspace(db)).id;
spaceId = (await createSpace(db, wsId)).id;
});
afterAll(async () => {
await destroyTestDb();
});
const docWith = (text: string) => ({
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text }] },
],
});
const newPage = async (
text = 'live body',
parentPageId?: string,
): Promise<string> => {
const { id } = await createPage(db, { workspaceId: wsId, spaceId });
await db
.updateTable('pages')
.set({ content: docWith(text) as any, parentPageId: parentPageId ?? null })
.where('id', '=', id)
.execute();
return id;
};
const insertShare = async (args: {
pageId: string;
publishedMode?: string;
includeSubPages?: boolean;
}) => {
const id = randomUUID();
await db
.insertInto('shares')
.values({
id,
key: `k-${id.slice(0, 12)}`,
pageId: args.pageId,
includeSubPages: args.includeSubPages ?? false,
publishedMode: args.publishedMode ?? 'live',
creatorId: USER,
spaceId,
workspaceId: wsId,
})
.execute();
return id;
};
const manualRows = (pageId: string) =>
db
.selectFrom('pageHistory')
.select(['id', 'kind', 'title'])
.where('pageId', '=', pageId)
.where('kind', '=', 'manual')
.execute();
const publishedModeColumn = async () =>
db
.selectFrom('information_schema.columns' as any)
.select(['columnName', 'dataType', 'isNullable', 'columnDefault'] as any)
.where('tableName' as any, '=', 'shares')
.where('columnName' as any, '=', 'published_mode')
.executeTakeFirst();
// --- migration up/down round-trip ----------------------------------------
it('migration down() drops published_mode; up() re-adds it NOT NULL default live', async () => {
// global-setup already migrated up, so the column is present.
const before: any = await publishedModeColumn();
expect(before).toBeDefined();
expect(before.isNullable).toBe('NO');
expect(String(before.columnDefault)).toContain("'live'");
await publishedModeMigration.down(db as any);
expect(await publishedModeColumn()).toBeUndefined();
// Restore so the rest of the suite (and afterAll) sees the real schema.
await publishedModeMigration.up(db as any);
const after: any = await publishedModeColumn();
expect(after).toBeDefined();
expect(after.isNullable).toBe('NO');
});
// --- getShareForPage 4-point threading -----------------------------------
it('getShareForPage carries publishedMode for a DIRECT share (anchor select + returned object)', async () => {
const pageId = await newPage();
await insertShare({ pageId, publishedMode: 'approved' });
const share = await service.getShareForPage(pageId, wsId);
expect(share).toBeDefined();
expect(share!.publishedMode).toBe('approved');
});
it('getShareForPage carries publishedMode for a DESCENDANT share (recursive-union select)', async () => {
// Parent shared with includeSubPages + approved (inserted directly, bypassing
// the service XOR gate, purely to exercise the CTE union threading on a
// child that inherits the ancestor share).
const parentId = await newPage('parent body');
await insertShare({
pageId: parentId,
publishedMode: 'approved',
includeSubPages: true,
});
const childId = await newPage('child body', parentId);
const share = await service.getShareForPage(childId, wsId);
expect(share).toBeDefined();
// Resolved via the recursive union up to the ancestor share.
expect((share!.level as number) > 0).toBe(true);
expect(share!.publishedMode).toBe('approved');
});
// --- baseline auto-create on enable --------------------------------------
it('createShare(approved) mints the first manual baseline from current content', async () => {
const pageId = await newPage('baseline body');
const page = { id: pageId, spaceId } as any;
expect(await manualRows(pageId)).toHaveLength(0);
await service.createShare({
authUserId: USER,
workspaceId: wsId,
page,
createShareDto: { pageId, includeSubPages: false, publishedMode: 'approved' } as any,
});
const rows = await manualRows(pageId);
expect(rows).toHaveLength(1);
});
it('updateShare enabling approved on a live share mints the baseline; a second call is idempotent', async () => {
const pageId = await newPage('later-saved body');
const shareId = await insertShare({ pageId, publishedMode: 'live' });
expect(await manualRows(pageId)).toHaveLength(0);
await service.updateShare(shareId, {
shareId,
publishedMode: 'approved',
} as any);
expect(await manualRows(pageId)).toHaveLength(1);
// Idempotent: staying approved does not duplicate the baseline.
await service.updateShare(shareId, {
shareId,
searchIndexing: true,
} as any);
expect(await manualRows(pageId)).toHaveLength(1);
});
// --- XOR gate ------------------------------------------------------------
it('createShare rejects approved + includeSubPages', async () => {
const pageId = await newPage();
await expect(
service.createShare({
authUserId: USER,
workspaceId: wsId,
page: { id: pageId, spaceId } as any,
createShareDto: {
pageId,
includeSubPages: true,
publishedMode: 'approved',
} as any,
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('updateShare rejects setting approved while includeSubPages is already on', async () => {
const pageId = await newPage();
const shareId = await insertShare({ pageId, includeSubPages: true });
await expect(
service.updateShare(shareId, { shareId, publishedMode: 'approved' } as any),
).rejects.toBeInstanceOf(BadRequestException);
});
// --- resolveReadableSharePage content freeze -----------------------------
// --- atomicity regression lock (F1) --------------------------------------
// The share-write and the approved baseline mint MUST commit in ONE
// transaction. If a future change moves the mint off `trx` back onto
// `this.db`, an "approved" share could durably persist WITHOUT a baseline —
// the fail-open confidentiality hole the fix closed. These two tests fail the
// moment that atomicity is broken: with the mint outside the trx, the failed
// mint would no longer roll back the share row (test 1) / the mode UPDATE
// (test 2), so the post-condition assertions below would flip.
it('createShare(approved) rolls back the shares insert when the baseline mint throws', async () => {
const pageId = await newPage('mint-fail create body');
const page = { id: pageId, spaceId } as any;
// No manual baseline yet -> ensureApprovedBaseline will attempt a real mint.
expect(await manualRows(pageId)).toHaveLength(0);
// saveHistory is the WRITE the baseline mint performs; force it to throw so
// the transaction body rejects after the shares row was inserted.
const spy = jest
.spyOn(pageHistoryRepo, 'saveHistory')
.mockRejectedValueOnce(new Error('boom'));
try {
await expect(
service.createShare({
authUserId: USER,
workspaceId: wsId,
page,
createShareDto: {
pageId,
includeSubPages: false,
publishedMode: 'approved',
} as any,
}),
).rejects.toBeInstanceOf(BadRequestException);
// The whole transaction rolled back: NO shares row survived the failed
// mint. If the mint ran outside the trx, this row would persist.
const share = await shareRepo.findByPageId(pageId);
expect(share).toBeFalsy();
// And no orphan baseline was committed either.
expect(await manualRows(pageId)).toHaveLength(0);
} finally {
spy.mockRestore();
}
});
it('updateShare(->approved) rolls back the mode UPDATE when the baseline mint throws', async () => {
const pageId = await newPage('mint-fail update body');
// Existing LIVE share with no manual baseline.
const shareId = await insertShare({ pageId, publishedMode: 'live' });
expect(await manualRows(pageId)).toHaveLength(0);
const spy = jest
.spyOn(pageHistoryRepo, 'saveHistory')
.mockRejectedValueOnce(new Error('boom'));
try {
await expect(
service.updateShare(shareId, {
shareId,
publishedMode: 'approved',
} as any),
).rejects.toBeInstanceOf(BadRequestException);
// The UPDATE rolled back with the failed mint: the row is STILL 'live'.
// If the mint ran outside the trx, publishedMode would be 'approved'.
const after = await shareRepo.findById(shareId);
expect(after).toBeDefined();
expect(after!.publishedMode).toBe('live');
// No orphan baseline committed.
expect(await manualRows(pageId)).toHaveLength(0);
} finally {
spy.mockRestore();
}
});
it('resolveReadableSharePage under approved swaps content+title but PRESERVES id + workspaceId', async () => {
const pageId = await newPage('LIVE draft body');
const shareId = await insertShare({ pageId, publishedMode: 'approved' });
// Seed a manual version distinct from the live draft.
await pageHistoryRepo.insertPageHistory({
pageId,
slugId: `hist-${pageId.slice(0, 8)}`,
title: 'SAVED title',
content: docWith('SAVED version body') as any,
kind: 'manual',
spaceId,
workspaceId: wsId,
} as any);
const resolved = await service.resolveReadableSharePage(
shareId,
pageId,
wsId,
);
expect(resolved).not.toBeNull();
// Content + title are the SAVED version.
expect(resolved!.page.title).toBe('SAVED title');
expect(JSON.stringify(resolved!.page.content)).toContain(
'SAVED version body',
);
// id + workspaceId stay LIVE (attachment-token ownership must not shift).
expect(resolved!.page.id).toBe(pageId);
expect(resolved!.page.workspaceId).toBe(wsId);
});
});