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:
a1chzt
2026-09-22 19:53:03 +08:00
14 changed files with 378 additions and 68 deletions
@@ -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 }) };
}
+12
View File
@@ -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'); }
}
+45 -11
View File
@@ -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' }; }
+16
View File
@@ -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(); }
});
+4 -3
View File
@@ -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?