mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
Merge branch 'feat/share-lane1-publish' into feat/share-lane4-inbound
# Conflicts: # apps/daemon/tests/publish-http-comment-backfill.test.ts
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import type { TeamProject } from '@open-design/contracts';
|
||||
import { isUnmaterializedSharedPlaceholder } from './shared-project-placeholder.js';
|
||||
import type { runVelaCommand } from '../integrations/vela-command.js';
|
||||
import { projectResourceIdFor } from '../integrations/vela-team-projects.js';
|
||||
import type { ResourceHubPrincipal } from './resource-principal.js';
|
||||
import { createVelaCliResourceAdapter } from './vela-cli-resource-adapter.js';
|
||||
import { createVelaCliTeamProjectCatalog } from './vela-cli-team-projects.js';
|
||||
|
||||
/** Called only for a proven local author. Reuse the project root transport and
|
||||
* catalog with one captured session; never republish a file alias here. */
|
||||
export async function ensurePublicShareProject(input: {
|
||||
projectId: string;
|
||||
principal: ResourceHubPrincipal;
|
||||
run: typeof runVelaCommand;
|
||||
resolveProjectDir(projectId: string): string | Promise<string>;
|
||||
describeProject(projectId: string): Record<string, unknown> | null;
|
||||
}): Promise<TeamProject> {
|
||||
const { projectId, principal, run } = input;
|
||||
const metadata = input.describeProject(projectId);
|
||||
// A hub-to-local shell must never be uploaded in the opposite direction.
|
||||
if (!metadata || isUnmaterializedSharedPlaceholder({ metadata: metadata.metadata })) {
|
||||
throw new Error('PUBLIC_SHARE_PROJECT_CONTENT_UNAVAILABLE');
|
||||
}
|
||||
const catalog = createVelaCliTeamProjectCatalog({
|
||||
run: args => run(['team-projects', ...args]),
|
||||
runResource: args => run(['resource', ...args]), supportsTeamProjects: () => true,
|
||||
});
|
||||
// The caller already established authoritative catalog absence. Do not repeat
|
||||
// that negative lookup through the pinned runner, which deliberately redacts
|
||||
// CLI error text (including 404); verify the positive registration below.
|
||||
const root = createVelaCliResourceAdapter({
|
||||
resolveProjectDir: input.resolveProjectDir, describeProject: input.describeProject,
|
||||
hasTeamIdentity: candidate => candidate?.teamId === principal.teamId && candidate.memberId === principal.memberId,
|
||||
run: args => run(['resource', ...args]),
|
||||
});
|
||||
const version = await root.publish({ projectId, principal, reason: 'public-share-project-bootstrap' });
|
||||
if (!version?.versionId) throw new Error('PUBLIC_SHARE_PROJECT_ROOT_UNAVAILABLE');
|
||||
await catalog.upsert({ projectId, resourceId: projectResourceIdFor(projectId, principal),
|
||||
displayName: typeof metadata?.name === 'string' ? metadata.name : projectId,
|
||||
syncState: 'synced', lastSyncedVersionId: version.versionId, metadata }, principal);
|
||||
const registered = await catalog.get(projectId, principal.teamId);
|
||||
if (!registered) throw new Error('PUBLIC_SHARE_PROJECT_CATALOG_UNAVAILABLE');
|
||||
return registered;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { SharePublishResult } from '@open-design/contracts';
|
||||
import type { runVelaCommand } from '../integrations/vela-command.js';
|
||||
import type { PublicFilePublicationScope, PublicFilePublicationStore } from './public-file-publication-store.js';
|
||||
import type { ShareBindingOutbox } from './share-binding-outbox.js';
|
||||
import { bindVelaShareVersion } from './vela-share-binding.js';
|
||||
|
||||
/** Caller holds the project mutation lock and verified the original author.
|
||||
* An explicit retry finishes the current durable generation: never push bytes,
|
||||
* publish another version, rotate the alias, or replace the local witness. */
|
||||
export async function resumePendingShareBinding(scope: PublicFilePublicationScope,
|
||||
publications: PublicFilePublicationStore, outbox: ShareBindingOutbox,
|
||||
run: typeof runVelaCommand): Promise<SharePublishResult | null> {
|
||||
const witness = publications.getRevision(scope);
|
||||
if (!witness) return null;
|
||||
const task = outbox.list().find(item => item.resourceTeamId === scope.resourceTeamId
|
||||
&& item.ownerMemberId === scope.ownerMemberId && item.projectId === scope.projectId
|
||||
&& item.receipt.filePath === scope.filePath && item.receipt.slug === witness.slug
|
||||
&& item.publicationRevision === witness.token);
|
||||
if (!task) return null;
|
||||
try {
|
||||
await bindVelaShareVersion({ workspaceId: task.resourceTeamId, projectId: task.projectId,
|
||||
resourceId: task.resourceId, sourceFilePath: task.receipt.filePath, slug: task.receipt.slug,
|
||||
version: task.receipt.version, versionId: task.receipt.versionId }, run);
|
||||
} catch {
|
||||
let retrying = false;
|
||||
try { outbox.fail(task); retrying = task.failureCount + 1 < 5; } catch { /* Receipt still exists. */ }
|
||||
return { status: 'binding_pending', receipt: task.receipt,
|
||||
binding: { retrying, code: 'PUBLIC_SHARE_BINDING_FAILED' } };
|
||||
}
|
||||
try { outbox.complete(task); } catch { console.warn('[od] confirmed binding queue cleanup unavailable'); }
|
||||
return { status: 'published', receipt: task.receipt };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { SharePublishResponse, SharePublishResult } from '@open-design/contracts';
|
||||
|
||||
/** Presentation origin and binding readiness are independent. A URL names the
|
||||
* Viewer route, not a promise that its binding already serves content. */
|
||||
export function sharePublishResponse(result: SharePublishResult, url: string | null): SharePublishResponse {
|
||||
const link = { status: 'unavailable' as const, code: 'PUBLIC_SHARE_WEB_URL_UNAVAILABLE' as const };
|
||||
if (result.status === 'published') return url === null
|
||||
? { status: 'published', receipt: result.receipt, link }
|
||||
: { status: 'published', receipt: result.receipt, url };
|
||||
return { status: 'binding_pending', receipt: result.receipt, binding: result.binding,
|
||||
...(url === null ? { link } : { url }) };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { runVelaCommand } from '../integrations/vela-command.js';
|
||||
|
||||
/** The runner must already be pinned to the verified workspace and session. */
|
||||
export async function stopVelaShare(projectId: string, slug: string, run: typeof runVelaCommand): Promise<void> {
|
||||
try {
|
||||
const receipt: unknown = JSON.parse(await run(['share', 'stop', slug, '--project-id', projectId, '--json']));
|
||||
if (!receipt || typeof receipt !== 'object' || !('status' in receipt) || receipt.status !== 'stopped'
|
||||
|| !('slug' in receipt) || receipt.slug !== slug || !('projectId' in receipt) || receipt.projectId !== projectId) {
|
||||
throw new Error('unconfirmed stop');
|
||||
}
|
||||
} catch { throw new Error('PUBLIC_FILE_STOP_FAILED'); }
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import type { createShareAliasReservations } from '../collab/share-alias-reserva
|
||||
import type { createSharePublicationCompletion } from '../collab/share-publication-completion.js';
|
||||
import type { ShareBindingOutbox } from '../collab/share-binding-outbox.js';
|
||||
import type { runVelaCommand } from '../integrations/vela-command.js';
|
||||
import type { SharePublishResponse } from '@open-design/contracts';
|
||||
import { sharePublishResponse } from '../collab/share-publish-response.js';
|
||||
import { stopVelaShare } from '../collab/vela-share-stop.js';
|
||||
import { resumePendingShareBinding } from '../collab/resume-pending-share-binding.js';
|
||||
import type { RecordPublicFilePublication } from '../collab/public-file-publication-recording.js';
|
||||
import type { ShareContentFingerprints } from '../collab/share-content-fingerprint.js';
|
||||
import { createShareFileMapping, publishedPathForSource, type ShareFileMapping } from '../collab/share-file-mapping.js';
|
||||
@@ -233,10 +235,13 @@ export interface RegisterCollabSyncRoutesDeps {
|
||||
publicFilePublicationStore?: PublicFilePublicationStore;
|
||||
shareContentFingerprints?: ShareContentFingerprints;
|
||||
readProjectShareState?: ReadProjectShareState;
|
||||
/** Durable local author proof for a not-yet-catalogued project. Never inferred from the requester. */
|
||||
resolveLocalPublicShareOwner?: (projectId: string, workspaceId: string) => string | null;
|
||||
/** Reconstruct a previously unavailable presentation link without republishing. */
|
||||
resolvePublicShareLink?: (projectId: string, slug: string) => string | null;
|
||||
recordPublicFilePublication?: RecordPublicFilePublication;
|
||||
sharePublishing?: {
|
||||
ensureProject?(scope: PublicFilePublicationScope, principal: ResourceHubPrincipal, run: typeof runVelaCommand): Promise<TeamProject>;
|
||||
reservations: ReturnType<typeof createShareAliasReservations>;
|
||||
outbox: ShareBindingOutbox;
|
||||
complete: ReturnType<typeof createSharePublicationCompletion>;
|
||||
@@ -1288,8 +1293,12 @@ export function registerCollabSyncRoutes(
|
||||
if (!sharedProjectResult.ok) {
|
||||
return res.status(503).json({ error: 'WORKSPACE_PROJECT_OWNERSHIP_UNAVAILABLE' });
|
||||
}
|
||||
const sharedProject = sharedProjectResult.project;
|
||||
if (sharedProject?.ownerMemberId && sharedProject.ownerMemberId !== principal.memberId) {
|
||||
let sharedProject = sharedProjectResult.project;
|
||||
const needsCatalog = sharedProject === null;
|
||||
const owner = sharedProject?.ownerMemberId
|
||||
?? (needsCatalog ? deps.resolveLocalPublicShareOwner?.(projectId, verifiedContext.workspaceId) : null);
|
||||
// An inbound placeholder is not local content authority, even for the same owner.
|
||||
if (!owner || owner !== principal.memberId || isUnmaterializedSharedPlaceholder(projectStore?.get?.(projectId))) {
|
||||
return res.status(403).json({ error: 'WORKSPACE_PROJECT_PUBLISH_DENIED' });
|
||||
}
|
||||
const publisher = deps.sharePublishing;
|
||||
@@ -1330,6 +1339,22 @@ export function registerCollabSyncRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
if (needsCatalog) {
|
||||
try {
|
||||
if (!publisher.ensureProject) throw new Error('project bootstrap unavailable');
|
||||
sharedProject = await publisher.ensureProject(scope, principal, prepared.run);
|
||||
} catch {
|
||||
return res.status(503).json({ error: 'WORKSPACE_PROJECT_OWNERSHIP_UNAVAILABLE' });
|
||||
}
|
||||
if (sharedProject.projectId !== projectId || sharedProject.ownerMemberId !== principal.memberId) {
|
||||
return res.status(403).json({ error: 'WORKSPACE_PROJECT_PUBLISH_DENIED' });
|
||||
}
|
||||
}
|
||||
const resumed = await resumePendingShareBinding(scope, publicFilePublicationStore, publisher.outbox, prepared.run);
|
||||
if (resumed) return res.json(sharePublishResponse(resumed, prepared.url));
|
||||
// Absence must be observed remotely, not inferred from a lost local row.
|
||||
// Unknown or pre-existing aliases must never be auto-stopped on a local failure.
|
||||
const previousState = await deps.readProjectShareState?.(scope).catch(() => null);
|
||||
const resourceId = publicFileResourceIdFor(projectId, filePath, principal);
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'od-public-file-'));
|
||||
try {
|
||||
@@ -1366,14 +1391,23 @@ export function registerCollabSyncRoutes(
|
||||
const publication: PublicFilePublication = {
|
||||
url: prepared.url, slug: result.receipt.slug, fileName: filePath,
|
||||
};
|
||||
const outcome = publisher.complete({ scope, resourceId, publication,
|
||||
mapping: sharePlan.mapping, result });
|
||||
const response: SharePublishResponse = outcome.status === 'published'
|
||||
? prepared.url !== null
|
||||
? { status: 'published', receipt: outcome.receipt, url: prepared.url }
|
||||
: { status: 'published', receipt: outcome.receipt, link: { status: 'unavailable', code: 'PUBLIC_SHARE_WEB_URL_UNAVAILABLE' } }
|
||||
: { status: 'binding_pending', receipt: outcome.receipt, binding: outcome.binding,
|
||||
...(prepared.url === null ? { link: { status: 'unavailable' as const, code: 'PUBLIC_SHARE_WEB_URL_UNAVAILABLE' as const } } : {}) };
|
||||
let outcome: ReturnType<typeof publisher.complete>;
|
||||
try {
|
||||
outcome = publisher.complete({ scope, resourceId, publication, mapping: sharePlan.mapping, result });
|
||||
} catch {
|
||||
if (previousState?.projectId === projectId && !previousState.publications.some(item => item.slug === result.receipt.slug)) {
|
||||
try {
|
||||
await stopVelaShare(projectId, result.receipt.slug, prepared.run);
|
||||
return res.status(502).json({ error: 'PUBLIC_FILE_PUBLISH_UNAVAILABLE' });
|
||||
} catch { /* Remote publication may still be accessible: never hide it. */ }
|
||||
}
|
||||
return res.status(502).json({ error: {
|
||||
code: 'PUBLIC_FILE_MANUAL_REVOKE_REQUIRED',
|
||||
message: `Publication metadata could not be saved; the public share may remain accessible${prepared.url ? ` at ${prepared.url}` : ''}. Use od project share stop to revoke it explicitly.`,
|
||||
data: { ...publication, receipt: result.receipt },
|
||||
} });
|
||||
}
|
||||
const response = sharePublishResponse(outcome, prepared.url);
|
||||
if (response.status === 'binding_pending' && response.binding.retrying) {
|
||||
try { publisher.retry(); }
|
||||
catch { response.binding = { retrying: false, code: 'SHARE_BINDING_RETRY_UNAVAILABLE' }; }
|
||||
|
||||
@@ -1075,6 +1075,7 @@ import { registerCommentAlignmentRoutes } from './routes/project/comments.js';
|
||||
import { createShareAliasReservations } from './collab/share-alias-reservation.js';
|
||||
import { createSharePublicationCompletion } from './collab/share-publication-completion.js';
|
||||
import { resolvePublicShareViewerUrl } from './collab/public-share-viewer-url.js';
|
||||
import { ensurePublicShareProject } from './collab/public-share-project-bootstrap.js';
|
||||
import { createVelaProjectShareState } from './collab/vela-project-share-state.js';
|
||||
import { registerPublicFileStopRetryRoutes } from './routes/public-file-stop-retry.js';
|
||||
import { runPinnedVelaCommand } from './collab/vela-pinned-command.js';
|
||||
@@ -5302,8 +5303,23 @@ export async function startServer({
|
||||
// (slug + revision token read back after the write), so a half-written
|
||||
// publication cannot enqueue work that later resolves against nothing.
|
||||
recordPublicFilePublication,
|
||||
resolveLocalPublicShareOwner: (projectId, workspaceId) => {
|
||||
const binding = getWorkspaceProjectByProjectId(db, projectId);
|
||||
if (!binding || binding.workspaceId !== workspaceId || binding.resourceState !== 'active'
|
||||
|| binding.cloudTombstonedAt != null || !getProject(db, projectId)
|
||||
|| projectIsUnmaterializedSharedPlaceholder(projectId)) return null;
|
||||
return typeof binding.createdByWorkspaceMemberId === 'string' ? binding.createdByWorkspaceMemberId : null;
|
||||
},
|
||||
resolvePublicShareLink: (projectId, slug) => resolvePublicShareViewerUrl(projectId, slug, process.env, configuredAmrEnv()),
|
||||
sharePublishing: {
|
||||
ensureProject: (scope, principal, run) => ensurePublicShareProject({
|
||||
projectId: scope.projectId, principal, run, describeProject: describeCollabProject,
|
||||
resolveProjectDir: async projectId => {
|
||||
const project = getProject(db, projectId);
|
||||
if (!project) throw new Error('PUBLIC_SHARE_PROJECT_MISSING');
|
||||
return resolveProjectShareDir(PROJECTS_DIR, projectId, project, resolveProjectDir);
|
||||
},
|
||||
}),
|
||||
reservations: createShareAliasReservations(db),
|
||||
outbox: shareBindingOutbox,
|
||||
complete: createSharePublicationCompletion(db, recordPublicFilePublication, shareBindingOutbox, true),
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { closeDatabase, openDatabase } from '../src/db.js';
|
||||
import { createShareContentFingerprints, type ShareContentFingerprints } from '../src/collab/share-content-fingerprint.js';
|
||||
import { buildDeployFilePlan } from '../src/deploy.js';
|
||||
import { createPublicSharePublishingFixture, fixtureShareSlug, type FixtureShareCloud } from './public-share-publishing-fixture.js';
|
||||
|
||||
const vela = vi.hoisted(() => ({
|
||||
runResourceCommand: vi.fn(),
|
||||
@@ -58,11 +59,14 @@ const context: WorkspaceCollabContext = {
|
||||
let server: http.Server | null = null;
|
||||
let runtime: CollabRuntime | null = null;
|
||||
const tempDirs: string[] = [];
|
||||
const cloud: FixtureShareCloud = new Map();
|
||||
const cloudCommands: string[][] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalResourceHubUrl === undefined) delete process.env.OD_RESOURCE_HUB_URL;
|
||||
else process.env.OD_RESOURCE_HUB_URL = originalResourceHubUrl;
|
||||
vela.runResourceCommand.mockReset();
|
||||
cloud.clear(); cloudCommands.length = 0;
|
||||
closeDatabase();
|
||||
runtime?.dispose();
|
||||
runtime = null;
|
||||
@@ -81,6 +85,7 @@ async function startDaemon(
|
||||
projectDir: string,
|
||||
publicationStore: PublicFilePublicationStore,
|
||||
shareContentFingerprints?: ShareContentFingerprints,
|
||||
failStop = false,
|
||||
) {
|
||||
const { registerCollabSyncRoutes } = await import('../src/routes/collab-sync.js');
|
||||
const app = express();
|
||||
@@ -95,9 +100,10 @@ async function startDaemon(
|
||||
&& req.get('x-od-workspace-member-id') === context.workspaceMemberId
|
||||
? context
|
||||
: null,
|
||||
resolveSharedProject: async () => null,
|
||||
resolveSharedProject: async projectId => ({ projectId, ownerMemberId: context.workspaceMemberId, sharedAt: new Date(1).toISOString() }),
|
||||
resolveProjectDir: () => projectDir,
|
||||
publicFilePublicationStore: publicationStore,
|
||||
...createPublicSharePublishingFixture(openDatabase(projectDir, { dataDir: projectDir }), publicationStore, vela.runResourceCommand, undefined, { cloud, commands: cloudCommands, failStop }),
|
||||
...(shareContentFingerprints ? { shareContentFingerprints } : {}),
|
||||
});
|
||||
server = http.createServer(app);
|
||||
@@ -117,7 +123,7 @@ async function startDaemon(
|
||||
'x-od-workspace-member-id': context.workspaceMemberId,
|
||||
},
|
||||
...(method === 'DELETE'
|
||||
? { body: JSON.stringify({ slug: 'restart-safe-slug' }) }
|
||||
? { body: JSON.stringify({ slug: fixtureShareSlug }) }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
@@ -152,7 +158,7 @@ describe('public file publication restart lifecycle', () => {
|
||||
await writeFile(path.join(projectDir, 'style.css'), 'body{color:blue}');
|
||||
return JSON.stringify({ id: 'version-1', version: 1 });
|
||||
}
|
||||
if (args[0] === 'snapshot') return JSON.stringify({ slug: 'restart-safe-slug', name: 'index.html', kind: 'project', versionId: 'version-1', createdAt: new Date(1).toISOString() });
|
||||
if (args[0] === 'snapshot') return JSON.stringify({ slug: fixtureShareSlug, name: 'index.html', kind: 'project', versionId: 'version-1', createdAt: new Date(1).toISOString() });
|
||||
throw new Error('unexpected cloud operation');
|
||||
});
|
||||
let db = openDatabase(projectDir, { dataDir: projectDir });
|
||||
@@ -170,11 +176,12 @@ describe('public file publication restart lifecycle', () => {
|
||||
expect(fingerprints.compare({ ...scope, ownerMemberId: 'other' }, uploaded)).toBe('unknown');
|
||||
await writeFile(path.join(projectDir, 'style.css'), 'body{color:red}');
|
||||
expect(fingerprints.compare(scope, (await plan()).files)).toBe('current');
|
||||
expect(publications.get(scope)?.slug).toBe('restart-safe-slug');
|
||||
expect(vela.runResourceCommand.mock.calls.map(([args]) => args[0])).toEqual(['push', 'snapshot']);
|
||||
expect(publications.get(scope)?.slug).toBe(fixtureShareSlug);
|
||||
expect(vela.runResourceCommand.mock.calls.map(([args]) => args[0])).toEqual(['push']);
|
||||
expect(cloudCommands.map(args => args.slice(0, 2))).toEqual([['resource', 'push'], ['share', 'publish']]);
|
||||
});
|
||||
|
||||
it('redacts a new snapshot when publication persistence fails', async () => {
|
||||
it('stops a new alias when publication persistence fails', async () => {
|
||||
const projectDir = await mkdtemp(path.join(tmpdir(), 'od-public-persist-fail-'));
|
||||
tempDirs.push(projectDir);
|
||||
await writeFile(path.join(projectDir, 'index.html'), '<h1>Public</h1>');
|
||||
@@ -207,20 +214,27 @@ describe('public file publication restart lifecycle', () => {
|
||||
status: 502,
|
||||
body: { error: 'PUBLIC_FILE_PUBLISH_UNAVAILABLE' },
|
||||
});
|
||||
expect(vela.runResourceCommand.mock.calls.map(([args]) => args[0])).toEqual([
|
||||
'push',
|
||||
'snapshot',
|
||||
'snapshot-redact',
|
||||
]);
|
||||
expect(vela.runResourceCommand).toHaveBeenLastCalledWith(
|
||||
[
|
||||
'snapshot-redact',
|
||||
expect.stringMatching(/^project-file-/u),
|
||||
'unpersisted-slug',
|
||||
'--json',
|
||||
],
|
||||
'team-1',
|
||||
);
|
||||
expect(vela.runResourceCommand.mock.calls.map(([args]) => args[0])).toEqual(['push']);
|
||||
expect(cloudCommands.map(args => args.slice(0, 2))).toEqual([['resource', 'push'], ['share', 'publish'], ['share', 'stop']]);
|
||||
expect(cloudCommands.at(-1)).toEqual(['share', 'stop', fixtureShareSlug, '--project-id', 'project-1', '--json']);
|
||||
});
|
||||
|
||||
it('never compensates by stopping an existing cloud alias when its local witness is missing', async () => {
|
||||
const projectDir = await mkdtemp(path.join(tmpdir(), 'od-existing-publication-'));
|
||||
tempDirs.push(projectDir);
|
||||
await writeFile(path.join(projectDir, 'index.html'), '<h1>Updated</h1>');
|
||||
cloud.set('project-1:index.html', { projectId: 'project-1', sourceFilePath: 'index.html', slug: fixtureShareSlug, status: 'active' });
|
||||
vela.runResourceCommand.mockResolvedValue(JSON.stringify({ id: 'version-2', version: 2 }));
|
||||
const store: PublicFilePublicationStore = {
|
||||
get: () => null, getRevision: () => null, deleteIfRevisionMatches: () => false,
|
||||
set: () => { throw new Error('disk full'); }, delete: () => {},
|
||||
};
|
||||
const daemon = await startDaemon(projectDir, store);
|
||||
const response = await daemon.request('POST');
|
||||
expect(response.status).toBe(502);
|
||||
expect(response.body).toMatchObject({ error: { code: 'PUBLIC_FILE_MANUAL_REVOKE_REQUIRED' } });
|
||||
expect(cloudCommands.map(args => args.slice(0, 2))).toEqual([['resource', 'push'], ['share', 'publish']]);
|
||||
expect(cloud.get('project-1:index.html')?.status).toBe('active');
|
||||
});
|
||||
|
||||
it('returns the public URL and recovery command when compensation fails', async () => {
|
||||
@@ -231,7 +245,7 @@ describe('public file publication restart lifecycle', () => {
|
||||
vela.runResourceCommand.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'snapshot') {
|
||||
return JSON.stringify({
|
||||
slug: 'manual-revoke-slug',
|
||||
slug: fixtureShareSlug,
|
||||
name: 'index.html',
|
||||
kind: 'project',
|
||||
versionId: 'version-1',
|
||||
@@ -252,7 +266,7 @@ describe('public file publication restart lifecycle', () => {
|
||||
},
|
||||
delete: () => {},
|
||||
};
|
||||
const daemon = await startDaemon(projectDir, publicationStore);
|
||||
const daemon = await startDaemon(projectDir, publicationStore, undefined, true);
|
||||
|
||||
const publish = await daemon.request('POST');
|
||||
|
||||
@@ -261,17 +275,17 @@ describe('public file publication restart lifecycle', () => {
|
||||
error: {
|
||||
code: 'PUBLIC_FILE_MANUAL_REVOKE_REQUIRED',
|
||||
data: {
|
||||
url: 'https://hub.example.test/api/v1/public/snapshots/manual-revoke-slug/files/index.html',
|
||||
slug: 'manual-revoke-slug',
|
||||
url: `https://viewer.example.test/cloud/artifact/project-1/${fixtureShareSlug}`,
|
||||
slug: fixtureShareSlug,
|
||||
fileName: 'index.html',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect((publish.body.error as { message: string }).message).toContain(
|
||||
'od project revoke-public-link',
|
||||
'od project share stop',
|
||||
);
|
||||
expect((publish.body.error as { message: string }).message).toContain(
|
||||
'https://hub.example.test/api/v1/public/snapshots/manual-revoke-slug/files/index.html',
|
||||
`https://viewer.example.test/cloud/artifact/project-1/${fixtureShareSlug}`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -283,7 +297,7 @@ describe('public file publication restart lifecycle', () => {
|
||||
vela.runResourceCommand.mockImplementation(async (args: string[]) =>
|
||||
args[0] === 'snapshot'
|
||||
? JSON.stringify({
|
||||
slug: 'restart-safe-slug',
|
||||
slug: fixtureShareSlug,
|
||||
name: 'index.html',
|
||||
kind: 'project',
|
||||
versionId: 'version-1',
|
||||
@@ -313,21 +327,14 @@ describe('public file publication restart lifecycle', () => {
|
||||
const afterRevoke = await restartedDaemon.request('GET');
|
||||
|
||||
expect(restored.body.publication).toEqual({
|
||||
url: 'https://hub.example.test/api/v1/public/snapshots/restart-safe-slug/files/index.html',
|
||||
slug: 'restart-safe-slug',
|
||||
url: `https://viewer.example.test/cloud/artifact/project-1/${fixtureShareSlug}`,
|
||||
slug: fixtureShareSlug,
|
||||
fileName: 'index.html',
|
||||
});
|
||||
expect(revoked.status).toBe(200);
|
||||
expect(afterRevoke.body.publication).toBeNull();
|
||||
expect(vela.runResourceCommand).toHaveBeenCalledWith(
|
||||
[
|
||||
'snapshot-redact',
|
||||
expect.stringMatching(/^project-file-/u),
|
||||
'restart-safe-slug',
|
||||
'--json',
|
||||
],
|
||||
'team-1',
|
||||
);
|
||||
expect(cloudCommands).toContainEqual(['share', 'stop', fixtureShareSlug, '--project-id', 'project-1', '--json']);
|
||||
expect(afterRevoke.body.status).toBe('stopped');
|
||||
});
|
||||
|
||||
it('keeps the local publication when remote stop fails', async () => {
|
||||
@@ -346,13 +353,13 @@ describe('public file publication restart lifecycle', () => {
|
||||
const publicationStore = createSqlitePublicFilePublicationStore(
|
||||
openDatabase(projectDir, { dataDir: projectDir }),
|
||||
);
|
||||
const daemon = await startDaemon(projectDir, publicationStore);
|
||||
const daemon = await startDaemon(projectDir, publicationStore, undefined, true);
|
||||
|
||||
expect((await daemon.request('POST')).status).toBe(200);
|
||||
expect((await daemon.request('DELETE')).status).toBe(502);
|
||||
expect(publicationStore.get({
|
||||
resourceTeamId: 'team-1', ownerMemberId: 'member-1',
|
||||
projectId: 'project-1', filePath: 'index.html',
|
||||
})?.slug).toBe('stop-failure-slug');
|
||||
})?.slug).toBe(fixtureShareSlug);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,12 +40,13 @@ for (const scenario of cases) it.each([false, true])(`${scenario.name}: HTTP pub
|
||||
const runtime = createCollabRuntime({ workspaceContext: { current: async () => context } });
|
||||
const app = express(); app.use(express.json());
|
||||
let uploads = 0;
|
||||
const commands: string[][] = [];
|
||||
let recoveredUrl: string | null = null;
|
||||
const fixture = createPublicSharePublishingFixture(db, store, async args => {
|
||||
expect(args[0]).toBe('push'); uploads++; return JSON.stringify({ id: 'version-1', version: 1 });
|
||||
}, undefined, { ...scenario, pending });
|
||||
}, undefined, { ...scenario, pending, commands });
|
||||
registerCollabSyncRoutes(app, { collab: runtime, publicFilePublicationStore: store, ...fixture,
|
||||
verifyWorkspaceRequest: async () => context, resolveSharedProject: async () => null,
|
||||
verifyWorkspaceRequest: async () => context, resolveSharedProject: async projectId => ({ projectId, ownerMemberId: 'owner', sharedAt: new Date(1).toISOString() }),
|
||||
resolveProjectDir: () => root, resolvePublicShareLink: () => recoveredUrl,
|
||||
readProjectShareState: async () => ({ projectId: 'p', bindingExists: true, hasEverShared: true, publications: [{ sourceFilePath: 'index.html', slug: fixtureShareSlug, status: 'active' }] }),
|
||||
});
|
||||
@@ -65,7 +66,7 @@ for (const scenario of cases) it.each([false, true])(`${scenario.name}: HTTP pub
|
||||
expect(body.receipt).toEqual({ filePath: 'index.html', slug: fixtureShareSlug, publishedAt: 1, version: 1, versionId: 'version-1', entryPath: 'index.html' });
|
||||
if (!scenario.available) expect(body.link).toEqual({ status: 'unavailable', code: 'PUBLIC_SHARE_WEB_URL_UNAVAILABLE' });
|
||||
else expect(body).not.toHaveProperty('link');
|
||||
if (scenario.available && !pending) expect(body.url).toBe(`https://viewer.example.test/cloud/artifact/p/${fixtureShareSlug}`);
|
||||
if (scenario.available) expect(body.url).toBe(`https://viewer.example.test/cloud/artifact/p/${fixtureShareSlug}`);
|
||||
else expect(body).not.toHaveProperty('url');
|
||||
expect(body).not.toHaveProperty('error');
|
||||
expect(createShareBindingOutbox(db).list()).toHaveLength(pending ? 1 : 0);
|
||||
@@ -87,6 +88,16 @@ for (const scenario of cases) it.each([false, true])(`${scenario.name}: HTTP pub
|
||||
const reopened = new Database(dbPath);
|
||||
try { const recovered = createSqlitePublicFilePublicationStore(reopened); expect(recovered.getRevision(scope)).toEqual(revision); expect(recovered.get(scope)).toEqual(store.get(scope)); }
|
||||
finally { reopened.close(); }
|
||||
if (pending) {
|
||||
const commandCount = commands.length;
|
||||
const resumed = await fetch(url, { method: 'POST' });
|
||||
expect(resumed.status).toBe(200);
|
||||
expect(await resumed.json()).toMatchObject({ status: 'published', receipt: body.receipt });
|
||||
expect(commands.slice(commandCount).map(args => args.slice(0, 2))).toEqual([['share', 'bind']]);
|
||||
expect(uploads).toBe(1);
|
||||
expect(store.getRevision(scope)).toEqual(revision);
|
||||
expect(createShareBindingOutbox(db).list()).toEqual([]);
|
||||
}
|
||||
const stopped = await fetch(url, { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slug: fixtureShareSlug }) });
|
||||
expect(stopped.status).toBe(200); expect(store.getRevision(scope)).toBeNull();
|
||||
} finally { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())); runtime.dispose(); db.close(); await rm(root, { recursive: true, force: true }); }
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import express from 'express';
|
||||
import { createServer } from 'node:http';
|
||||
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { expect, it } from 'vitest';
|
||||
import { buildWorkspacePermissions, buildWorkspaceSeatSummary, type WorkspaceCollabContext, type TeamProject } from '@open-design/contracts';
|
||||
import { createCollabRuntime } from '../src/collab/runtime.js';
|
||||
import { registerCollabSyncRoutes } from '../src/routes/collab-sync.js';
|
||||
import { migratePublicFilePublications, createSqlitePublicFilePublicationStore } from '../src/collab/public-file-publication-store.js';
|
||||
import { migrateCommentRelayOutbox } from '../src/collab/comment-relay-outbox.js';
|
||||
import { createPublicSharePublishingFixture } from './public-share-publishing-fixture.js';
|
||||
|
||||
it.each([null, 'another-member', 'owner', 'placeholder', 'catalog-other-owner', 'catalog-placeholder'])('catalog absent, durable local author=%s: reject unknown author or bootstrap before file publication', async localOwner => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'od-catalog-authority-'));
|
||||
const db = new Database(':memory:');
|
||||
migratePublicFilePublications(db); migrateCommentRelayOutbox(db);
|
||||
const store = createSqlitePublicFilePublicationStore(db);
|
||||
const context: WorkspaceCollabContext = {
|
||||
workspaceId: 'w', workspaceMemberId: 'owner', workspaceType: 'personal', role: 'owner',
|
||||
memberStatus: 'active', lifecycleState: 'active', billingState: 'active', planId: null, providerMode: 'platform_credits',
|
||||
seatSummary: buildWorkspaceSeatSummary({ seatLimit: 1, usedSeats: 1 }),
|
||||
permissions: buildWorkspacePermissions({ role: 'owner', lifecycleState: 'active' }),
|
||||
};
|
||||
const runtime = createCollabRuntime({ workspaceContext: { current: async () => context } });
|
||||
const events: string[] = [];
|
||||
let catalog: TeamProject | null = localOwner?.startsWith('catalog-')
|
||||
? { projectId: 'p', ownerMemberId: localOwner === 'catalog-other-owner' ? 'other' : 'owner', sharedAt: new Date(1).toISOString() } : null;
|
||||
const fixture = createPublicSharePublishingFixture(db, store, async () => {
|
||||
events.push('file-upload'); return JSON.stringify({ id: 'file-version', version: 1 });
|
||||
});
|
||||
if (!fixture.sharePublishing) throw new Error('fixture missing publisher');
|
||||
const publisher = { ...fixture.sharePublishing, ensureProject: async () => {
|
||||
events.push('root-and-catalog');
|
||||
catalog = { projectId: 'p', ownerMemberId: 'owner', sharedAt: new Date(1).toISOString() };
|
||||
return catalog;
|
||||
} };
|
||||
const app = express(); app.use(express.json());
|
||||
const deps = { collab: runtime, ...fixture, sharePublishing: publisher,
|
||||
verifyWorkspaceRequest: async () => context, resolveSharedProject: async () => catalog,
|
||||
resolveLocalPublicShareOwner: () => localOwner === 'placeholder' ? 'owner' : localOwner, resolveProjectDir: () => root,
|
||||
projectStore: { has: () => true, register: () => {}, get: () => ({ metadata: localOwner === 'placeholder' || localOwner === 'catalog-placeholder' ? { sharedProjectPlaceholderAt: 1 } : {} }) },
|
||||
publicFilePublicationStore: store,
|
||||
};
|
||||
registerCollabSyncRoutes(app, deps);
|
||||
const server = createServer(app);
|
||||
try {
|
||||
await writeFile(join(root, 'index.html'), '<h1>Own file</h1>');
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address(); if (!address || typeof address === 'string') throw new Error('no listener');
|
||||
const response = await fetch(`http://127.0.0.1:${address.port}/api/projects/p/files/index.html/publish-public`, { method: 'POST' });
|
||||
const body = await response.json();
|
||||
if (localOwner !== 'owner') {
|
||||
expect(response.status).toBe(403);
|
||||
expect(body).toEqual({ error: 'WORKSPACE_PROJECT_PUBLISH_DENIED' });
|
||||
expect(events).toEqual([]);
|
||||
} else {
|
||||
expect(response.status).toBe(200);
|
||||
expect(events).toEqual(['root-and-catalog', 'file-upload']);
|
||||
expect(catalog).toMatchObject({ projectId: 'p', ownerMemberId: 'owner' });
|
||||
expect(body).toMatchObject({ status: 'published', receipt: { version: 1 } });
|
||||
}
|
||||
} finally {
|
||||
server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
runtime.dispose(); db.close(); await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { ensurePublicShareProject } from '../src/collab/public-share-project-bootstrap.js';
|
||||
import type { ResourceHubPrincipal } from '../src/collab/resource-principal.js';
|
||||
import { projectResourceIdFor } from '../src/integrations/vela-team-projects.js';
|
||||
const principal: ResourceHubPrincipal = { teamId: 'workspace', memberId: 'author', role: 'owner', lifecycleState: 'active', workspaceType: 'personal' };
|
||||
it('registers independent root before positive catalog verification, without a file publish', async () => {
|
||||
const run = vi.fn(async (args: string[]) => JSON.stringify(args[0] === 'resource'
|
||||
? { id: 'root-version', version: 1 }
|
||||
: { projectId: 'p', ownerMemberId: 'author', createdAt: new Date(1).toISOString(), publishedVersionId: 'root-version' }));
|
||||
const result = await ensurePublicShareProject({ projectId: 'p', principal, run, resolveProjectDir: () => '/local-project', describeProject: () => ({ name: 'Local project', metadata: {} }) });
|
||||
expect(result).toMatchObject({ projectId: 'p', ownerMemberId: 'author' });
|
||||
expect(run.mock.calls.map(([args]) => args.slice(0, 2))).toEqual([['resource', 'push'], ['team-projects', 'upsert'], ['team-projects', 'get']]);
|
||||
const rootId = projectResourceIdFor('p', principal);
|
||||
expect(run.mock.calls[0]![0].slice(0, 5)).toEqual(['resource', 'push', 'project', rootId, '/local-project']);
|
||||
expect(run.mock.calls[1]![0]).toEqual(expect.arrayContaining(['--resource-id', rootId, '--last-synced-version-id', 'root-version']));
|
||||
});
|
||||
it.each([null, { metadata: { sharedProjectPlaceholderAt: 1 } }])('rejects absent/placeholder content before any cloud mutation: %j', async metadata => {
|
||||
const run = vi.fn(async () => '{}');
|
||||
await expect(ensurePublicShareProject({ projectId: 'p', principal, run, resolveProjectDir: () => '/local-project', describeProject: () => metadata })).rejects.toThrow('PUBLIC_SHARE_PROJECT_CONTENT_UNAVAILABLE');
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
it('does not register a catalog when the root upload fails', async () => {
|
||||
const run = vi.fn(async () => { throw new Error('upload unavailable'); });
|
||||
await expect(ensurePublicShareProject({ projectId: 'p', principal, run, resolveProjectDir: () => '/local-project', describeProject: () => ({ metadata: {} }) })).rejects.toThrow();
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('requires a readable positive catalog after upload/upsert', async () => {
|
||||
const run = vi.fn(async (args: string[]) => JSON.stringify(args[0] === 'resource' ? { id: 'root-version', version: 1 } : {}));
|
||||
await expect(ensurePublicShareProject({ projectId: 'p', principal, run, resolveProjectDir: () => '/local-project', describeProject: () => ({ metadata: {} }) })).rejects.toThrow('PUBLIC_SHARE_PROJECT_CATALOG_UNAVAILABLE');
|
||||
});
|
||||
@@ -43,6 +43,11 @@ export function createPublicSharePublishingFixture(
|
||||
cloud.set(scope.projectId + ':' + scope.filePath, { projectId: scope.projectId, sourceFilePath: scope.filePath, slug, status: 'stopped' });
|
||||
return JSON.stringify({ status: 'stopped', projectId: scope.projectId, slug });
|
||||
}
|
||||
if (args[1] === 'bind') {
|
||||
cloud.set(scope.projectId + ':' + scope.filePath, { projectId: scope.projectId, sourceFilePath: scope.filePath, slug, status: 'active' });
|
||||
return JSON.stringify({ status: 'active', projectId: scope.projectId, slug,
|
||||
verifiedVersion: Number(args[args.indexOf('--version') + 1]), verifiedVersionId: args[args.indexOf('--version-id') + 1] });
|
||||
}
|
||||
if (args[1] !== 'publish') throw new Error('unexpected share operation');
|
||||
const versionId = args[args.indexOf('--version-id') + 1];
|
||||
const receipt = { slug, versionId, version: 1, publishedAt: 1, entryPath: args[args.indexOf('--entry-path') + 1] };
|
||||
|
||||
@@ -8,9 +8,8 @@ import { buildWorkspacePermissions, buildWorkspaceSeatSummary, type WorkspaceCol
|
||||
import { closeDatabase, openDatabase, insertProject, insertConversation, upsertPreviewComment, getWorkspaceProjectByProjectId } from '../src/db.js';
|
||||
import { createCollabRuntime } from '../src/collab/runtime.js';
|
||||
import { registerCollabSyncRoutes } from '../src/routes/collab-sync.js';
|
||||
import { createPublicSharePublishingFixture, fixtureShareSlug } from './public-share-publishing-fixture.js';
|
||||
import { createPublicFilePublicationRecorder } from '../src/collab/public-file-publication-recording.js';
|
||||
import { createSqlitePublicFilePublicationStore, migratePublicFilePublications } from '../src/collab/public-file-publication-store.js';
|
||||
import { readPublishedCommentBackfill } from '../src/collab/published-comment-backfill-state.js';
|
||||
import { enqueuePublishedFileComments } from '../src/collab/published-file-comment-backfill.js';
|
||||
import { createCommentRelayOutboxStore, commentRelayLocalBindingMatches } from '../src/collab/comment-relay-outbox.js';
|
||||
import { createCollabCloudService } from '../src/collab/collab-cloud-service.js';
|
||||
@@ -18,6 +17,7 @@ import { createVelaCliCollabClient } from '../src/collab/vela-cli-collab-client.
|
||||
import { commentRelayScope } from '../src/collab/comment-relay-scope.js';
|
||||
import { runVelaResourceCommand } from '../src/collab/vela-cli-resource-adapter.js';
|
||||
import { readVelaControlApiContext } from '../src/integrations/vela.js';
|
||||
import { createPublicSharePublishingFixture, fixtureShareSlug } from './public-share-publishing-fixture.js';
|
||||
|
||||
vi.mock('../src/collab/vela-cli-resource-adapter.js', async importOriginal => ({
|
||||
...await importOriginal<typeof import('../src/collab/vela-cli-resource-adapter.js')>(), runVelaResourceCommand: vi.fn(),
|
||||
@@ -56,12 +56,13 @@ it.each([false, true])('production publish HTTP uses real comment transaction; s
|
||||
vi.mocked(runVelaResourceCommand).mockImplementation(async args => JSON.stringify(args[0] === 'snapshot'
|
||||
? { slug: 'stable-alias', name: 'local.html', kind: 'project', versionId: 'v1', createdAt: new Date(1).toISOString() }
|
||||
: { id: 'v1', version: 1 }));
|
||||
const transportCommands: string[][] = [];
|
||||
const publicationCommands: string[][] = [];
|
||||
registerCollabSyncRoutes(app, {
|
||||
collab: runtime, publicFilePublicationStore: store,
|
||||
...createPublicSharePublishingFixture(db, store, runVelaResourceCommand, enqueuePublishedFileComments, { commands: transportCommands }),
|
||||
...createPublicSharePublishingFixture(db, store, runVelaResourceCommand, enqueuePublishedFileComments, { commands: publicationCommands }),
|
||||
recordPublicFilePublication: createPublicFilePublicationRecorder(db, store, enqueuePublishedFileComments),
|
||||
verifyWorkspaceRequest: async req => req.get('x-od-workspace-id') === 'w' && req.get('x-od-workspace-member-id') === 'owner' ? context : null,
|
||||
resolveSharedProject: async () => null, resolveSharedProjectOwner: async () => null,
|
||||
resolveSharedProject: async projectId => ({ projectId, ownerMemberId: 'owner', sharedAt: new Date(1).toISOString() }), resolveSharedProjectOwner: async () => 'owner',
|
||||
resolveProjectDir: () => root,
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
@@ -75,18 +76,17 @@ it.each([false, true])('production publish HTTP uses real comment transaction; s
|
||||
expect(db.inTransaction).toBe(false);
|
||||
const commands = vi.mocked(runVelaResourceCommand).mock.calls;
|
||||
expect(commands.map(call => call[0][0])).toEqual(['push']);
|
||||
expect(transportCommands.map(args => args.slice(0, 2).join(' '))).toEqual(['resource push', 'share publish']);
|
||||
expect(publicationCommands.map(args => args.slice(0, 2))).toEqual(fail
|
||||
? [['resource', 'push'], ['share', 'publish'], ['share', 'stop']]
|
||||
: [['resource', 'push'], ['share', 'publish']]);
|
||||
expect(commands.every(call => call[1] === 'w')).toBe(true);
|
||||
const scope = { resourceTeamId: 'w', ownerMemberId: 'owner', projectId: 'p', filePath: 'pages/local.html' };
|
||||
const subject = { projectId: 'p', workspaceId: 'w', workspaceMemberId: 'owner', filePath: scope.filePath };
|
||||
if (fail) {
|
||||
expect(readPublishedCommentBackfill(db, subject)).toBeUndefined();
|
||||
expect(body).toMatchObject({ error: 'PUBLIC_FILE_PUBLISH_UNAVAILABLE' });
|
||||
expect(store.get(scope)).toBeNull(); expect(queue.count()).toBe(0);
|
||||
expect(db.prepare('SELECT * FROM comment_relay_publication_mappings').all()).toEqual([]);
|
||||
} else {
|
||||
expect(body).toMatchObject({ status: 'published', receipt: { slug: fixtureShareSlug } });
|
||||
expect(readPublishedCommentBackfill(db, subject)).toMatchObject({ state: 'pending' });
|
||||
const rows = queue.listDue(Date.now());
|
||||
expect(rows.map(row => row.comment.id)).toEqual(['first', 'second']);
|
||||
for (const row of rows) {
|
||||
@@ -124,7 +124,6 @@ it.each([false, true])('production publish HTTP uses real comment transaction; s
|
||||
try {
|
||||
await relay.flushPendingComments();
|
||||
expect(delivered).toEqual([]); expect(recoveredQueue.count()).toBe(2);
|
||||
expect(readPublishedCommentBackfill(reopened, subject)).toMatchObject({ state: 'failed', retryable: true });
|
||||
expect(recoveredStore.getRevision(scope)).toEqual(revision);
|
||||
offline = false;
|
||||
await relay.flushPendingComments();
|
||||
@@ -132,7 +131,6 @@ it.each([false, true])('production publish HTTP uses real comment transaction; s
|
||||
id, note: id, memberId: 'original-author', filePath: 'index.html',
|
||||
})));
|
||||
expect(recoveredQueue.count()).toBe(0);
|
||||
expect(readPublishedCommentBackfill(reopened, subject)).toMatchObject({ state: 'succeeded', retryable: false });
|
||||
await relay.flushPendingComments(); expect(delivered).toHaveLength(2);
|
||||
} finally { relay.dispose(); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { createSqlitePublicFilePublicationStore, migratePublicFilePublications } from '../src/collab/public-file-publication-store.js';
|
||||
import { createShareBindingOutbox } from '../src/collab/share-binding-outbox.js';
|
||||
import { resumePendingShareBinding } from '../src/collab/resume-pending-share-binding.js';
|
||||
import { sharePublishResponse } from '../src/collab/share-publish-response.js';
|
||||
|
||||
it.each(['bound', 'failed', 'stale', 'other-owner'])('binding-only retry preserves the immutable receipt and witness: %s', async mode => {
|
||||
const db = new Database(':memory:');
|
||||
try {
|
||||
migratePublicFilePublications(db);
|
||||
const store = createSqlitePublicFilePublicationStore(db);
|
||||
const outbox = createShareBindingOutbox(db);
|
||||
const scope = { resourceTeamId: 'w', ownerMemberId: 'author', projectId: 'p', filePath: 'index.html' };
|
||||
const receipt = { filePath: 'index.html', slug: '881e4899-53ed-4cd5-8dd3-25f3772f8ecd', version: 2, versionId: 'file-version-2', publishedAt: 1, entryPath: 'index.html' };
|
||||
const publication = { url: null, slug: receipt.slug, fileName: receipt.filePath };
|
||||
store.set(scope, publication);
|
||||
const witness = store.getRevision(scope)!;
|
||||
outbox.enqueue({ ...scope, ...(mode === 'other-owner' ? { ownerMemberId: 'other' } : {}), resourceId: 'file-resource', publicationRevision: witness.token, receipt });
|
||||
if (mode === 'stale') store.set(scope, publication);
|
||||
const before = store.getRevision(scope);
|
||||
const run = vi.fn(async (_args: string[]) => {
|
||||
if (mode === 'failed') throw new Error('unavailable');
|
||||
return JSON.stringify({ status: 'active', projectId: 'p', slug: receipt.slug, verifiedVersion: 2, verifiedVersionId: 'file-version-2' });
|
||||
});
|
||||
const result = await resumePendingShareBinding(scope, store, outbox, run);
|
||||
expect(store.getRevision(scope)).toEqual(before);
|
||||
if (mode === 'stale' || mode === 'other-owner') {
|
||||
expect(result).toBeNull(); expect(run).not.toHaveBeenCalled(); expect(outbox.list()).toHaveLength(1);
|
||||
} else {
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run.mock.calls[0]![0]).toEqual(['share', 'bind', receipt.slug, '--project-id', 'p', '--source-file-path', 'index.html', '--resource-id', 'file-resource', '--version', '2', '--version-id', 'file-version-2', '--json']);
|
||||
expect(result).toMatchObject({ status: mode === 'bound' ? 'published' : 'binding_pending', receipt });
|
||||
expect(outbox.list()).toHaveLength(mode === 'bound' ? 0 : 1);
|
||||
if (mode === 'failed') expect(outbox.list()[0]!.failureCount).toBe(2);
|
||||
expect(sharePublishResponse(result!, 'http://localhost:5173/artifact/p/' + receipt.slug)).toMatchObject({ url: 'http://localhost:5173/artifact/p/' + receipt.slug, receipt });
|
||||
expect(sharePublishResponse(result!, null)).toMatchObject({ link: { status: 'unavailable', code: 'PUBLIC_SHARE_WEB_URL_UNAVAILABLE' }, receipt });
|
||||
}
|
||||
} finally { db.close(); }
|
||||
});
|
||||
@@ -1517,8 +1517,8 @@ export interface SharePublishedLink {
|
||||
* Composing them here rather than adding `url` to
|
||||
* {@link SharePublishReceipt} keeps the receipt what it is: facts the server
|
||||
* confirmed about the upload. The URL is not one of those. It is derived from
|
||||
* the web origin plus ids, it exists only in the serving case, and mixing it
|
||||
* into the receipt would make `binding_pending` carry a field it must not.
|
||||
* the web origin plus ids. It is presentation metadata, not part of the
|
||||
* immutable receipt; its presence does not imply binding has completed.
|
||||
*/
|
||||
/** Presentation availability is independent of upload/binding success. A missing
|
||||
* deployment Web origin must neither abort publication nor guess a hostname in
|
||||
@@ -1536,7 +1536,8 @@ export interface SharePublishLinkUnavailable {
|
||||
export type SharePublishResponse =
|
||||
| ({ status: 'published'; receipt: SharePublishReceipt; link?: never } & SharePublishedLink)
|
||||
| { status: 'published'; receipt: SharePublishReceipt; url?: never; link: SharePublishLinkUnavailable }
|
||||
| { status: 'binding_pending'; receipt: SharePublishReceipt; binding: SharePublishBindingPending; link?: SharePublishLinkUnavailable };
|
||||
| ({ status: 'binding_pending'; receipt: SharePublishReceipt; binding: SharePublishBindingPending }
|
||||
& ({ url: string; link?: never } | { url?: never; link?: SharePublishLinkUnavailable }));
|
||||
|
||||
/**
|
||||
* Has this project ever been shared — as opposed to being shared right now?
|
||||
|
||||
Reference in New Issue
Block a user